feat: add Drupal JSON:API service layer

- Create TypeScript interfaces for JSON:API responses (types/drupal.ts)
- Implement DrupalService class with getNodes(), getNode(), getTaxonomyTerms()
- Support pagination via getNextPage()
- Configure base URL via VITE_DRUPAL_API_URL environment variable
- Default uses CORS proxy at /api/drupal-proxy
- Add .env.example with configuration options

Phase1 tasks completed:
- Drupal instance with JSON:API verified working
- CORS proxy configured in nginx
- Content types created: article, page, learning_resource
- Taxonomies created: tags, categories
This commit is contained in:
BarnacleBoy 2026-05-14 19:40:54 +00:00
parent ee2a9cd2ee
commit 68792b9cf5
4 changed files with 385 additions and 0 deletions

View file

@ -0,0 +1,6 @@
# Drupal API Configuration
# Use the CORS proxy for local development
VITE_DRUPAL_API_URL=/api/drupal-proxy
# For direct access (requires CORS on Drupal)
# VITE_DRUPAL_API_URL=https://cms.viossa.net/jsonapi

View file

@ -0,0 +1,256 @@
/// <reference types="vite/client" />
/**
* Drupal JSON:API Service
* Provides typed access to Drupal content via JSON:API
*/
import type {
JsonApiParams,
JsonApiDocument,
JsonApiResource,
JsonApiLinks,
JsonApiError,
DrupalNodeAttributes,
DrupalTaxonomyTermAttributes,
} from "../types/drupal";
type ContentType = "article" | "page" | "learning_resource";
type Vocabulary = "tags" | "categories";
interface FetchOptions {
baseUrl?: string;
useProxy?: boolean;
}
interface NodeListParams extends JsonApiParams {
contentType: ContentType;
}
interface NodeParams extends JsonApiParams {
id: string;
contentType: ContentType;
include?: string[];
}
interface TaxonomyParams {
vocabulary: Vocabulary;
}
/**
* DrupalService - Client for Drupal JSON:API
*
* Supports two modes:
* 1. Direct: https://cms.viossa.net/jsonapi (requires CORS)
* 2. Proxy: /api/drupal-proxy/ (via viossa.net nginx)
*
* Default uses proxy to avoid CORS issues.
*/
export class DrupalService {
private baseUrl: string;
constructor(options: FetchOptions = {}) {
// Use environment variable or default to proxy path
const envUrl = import.meta.env.VITE_DRUPAL_API_URL;
if (options.baseUrl) {
this.baseUrl = options.baseUrl;
} else if (envUrl) {
this.baseUrl = envUrl;
} else if (options.useProxy === false) {
// Direct access to Drupal
this.baseUrl = "https://cms.viossa.net/jsonapi";
} else {
// Default: use proxy
this.baseUrl = "/api/drupal-proxy";
}
}
/**
* Build query string from JsonApiParams
*/
private buildQueryString(params: JsonApiParams = {}): string {
const queryParts: string[] = [];
// Include relationships
if (params.include?.length) {
queryParts.push(`include=${params.include.join(",")}`);
}
// Sparse fieldsets
if (params.fields) {
const fields = Object.entries(params.fields)
.map(([type, fields]) => `fields[${type}]=${fields.join(",")}`)
.join("&");
queryParts.push(fields);
}
// Sorting
if (params.sort?.length) {
queryParts.push(`sort=${params.sort.join(",")}`);
}
// Pagination
if (params.page) {
if (params.page.limit) {
queryParts.push(`page[limit]=${params.page.limit}`);
}
if (params.page.offset) {
queryParts.push(`page[offset]=${params.page.offset}`);
}
if (params.page.cursor) {
queryParts.push(`page[cursor]=${params.page.cursor}`);
}
}
// Filtering
if (params.filter) {
const buildFilter = (obj: Record<string, unknown>, prefix = "filter"): string[] => {
const parts: string[] = [];
for (const [key, value] of Object.entries(obj)) {
const paramKey = `${prefix}[${key}]`;
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
parts.push(...buildFilter(value as Record<string, unknown>, paramKey));
} else if (Array.isArray(value)) {
parts.push(`${paramKey}=${value.join(",")}`);
} else {
parts.push(`${paramKey}=${value}`);
}
}
return parts;
};
queryParts.push(...buildFilter(params.filter));
}
return queryParts.length > 0 ? `?${queryParts.join("&")}` : "";
}
/**
* Fetch wrapper with error handling
*/
private async fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: {
"Accept": "application/vnd.api+json",
},
});
if (!response.ok) {
const errorBody = await response.text();
let errors: JsonApiError[] = [];
try {
const errorJson = JSON.parse(errorBody);
errors = errorJson.errors || [];
} catch {
// NotJSON error
}
throw new DrupalApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
errors
);
}
return response.json();
}
/**
* Get nodes by content type
*/
async getNodes<T extends DrupalNodeAttributes = DrupalNodeAttributes>(
params: NodeListParams
): Promise<JsonApiDocument<T>> {
const { contentType, ...queryParams } = params;
const queryString = this.buildQueryString(queryParams);
const url = `${this.baseUrl}/node/${contentType}${queryString}`;
return this.fetchJson<JsonApiDocument<T>>(url);
}
/**
* Get a single node by ID
* Note: Drupal JSON:API uses /jsonapi/node/{type}/{uuid} for individual nodes
*/
async getNode<T extends DrupalNodeAttributes = DrupalNodeAttributes>(
params: NodeParams
): Promise<JsonApiDocument<T>> {
const { id, contentType, include } = params;
const queryParams: JsonApiParams = include ? { include } : {};
const queryString = this.buildQueryString(queryParams);
const url = `${this.baseUrl}/node/${contentType}/${id}${queryString}`;
return this.fetchJson<JsonApiDocument<T>>(url);
}
/**
* Get taxonomy terms by vocabulary
*/
async getTaxonomyTerms(
params: TaxonomyParams
): Promise<JsonApiDocument<DrupalTaxonomyTermAttributes>> {
const { vocabulary } = params;
const url = `${this.baseUrl}/taxonomy_term/${vocabulary}`;
return this.fetchJson<JsonApiDocument<DrupalTaxonomyTermAttributes>>(url);
}
/**
* Follow pagination links
*/
async getNextPage<T extends object>(links: JsonApiLinks): Promise<JsonApiDocument<T> | null> {
if (!links.next) return null;
// Handle both string and object link formats
const nextUrl = typeof links.next === "string" ? links.next : links.next;
if (!nextUrl) return null;
// If relative link, prepend base URL
const url = nextUrl.startsWith("/") ? `${this.baseUrl}${nextUrl}` : nextUrl;
return this.fetchJson<JsonApiDocument<T>>(url);
}
/**
* Helper: Extract data array from document
*/
extractData<T extends object>(document: JsonApiDocument<T>): JsonApiResource<T>[] {
return Array.isArray(document.data) ? document.data : [document.data];
}
/**
* Helper: Get attribute from resource
*/
getAttribute<T extends DrupalNodeAttributes>(resource: JsonApiResource<T>): T {
return resource.attributes;
}
/**
* Helper: Check for errors in response
*/
hasErrors(document: JsonApiDocument): boolean {
return document.errors !== undefined && document.errors.length > 0;
}
}
/**
* Custom error class for Drupal API errors
*/
export class DrupalApiError extends Error {
status: number;
errors: JsonApiError[];
constructor(
message: string,
status: number,
errors: JsonApiError[] = []
) {
super(message);
this.name = "DrupalApiError";
this.status = status;
this.errors = errors;
}
}
// Export singleton instance with default configuration
export const drupalService = new DrupalService();

View file

@ -0,0 +1,115 @@
/**
* TypeScript interfaces for Drupal JSON:API responses.
* Based on JSON:API specification 1.1
*/
// JSON:API Resource Object
export interface JsonApiResource<T extends object = Record<string, unknown>> {
type: string;
id: string;
attributes: T;
relationships?: Record<string, JsonApiRelationship>;
links?: JsonApiLinks;
}
// JSON:API Relationship Object
export interface JsonApiRelationship {
data: JsonApiResourceIdentifier | JsonApiResourceIdentifier[];
links?: JsonApiLinks;
}
// JSON:API Resource Identifier
export interface JsonApiResourceIdentifier {
type: string;
id: string;
}
// JSON:API Links Object
export interface JsonApiLinks {
self?: string;
next?: string;
prev?: string;
first?: string;
last?: string;
[key: string]: string | undefined;
}
// JSON:API Document (Top-level response)
export interface JsonApiDocument<T extends object = Record<string, unknown>> {
jsonapi?: { version: string; meta?: Record<string, unknown> };
data: JsonApiResource<T> | JsonApiResource<T>[];
included?: JsonApiResource[];
links?: JsonApiLinks;
meta?: Record<string, unknown>;
errors?: JsonApiError[];
}
// JSON:API Error Object
export interface JsonApiError {
id?: string;
status?: string;
code?: string;
title?: string;
detail?: string;
source?: { pointer?: string; parameter?: string };
links?: JsonApiLinks;
}
// JSON:API Query Parameters
export interface JsonApiParams {
include?: string[];
fields?: Record<string, string[]>;
sort?: string[];
page?: { limit?: number; offset?: number; cursor?: string };
filter?: Record<string, unknown>;
}
// Drupal Node Attributes (Base)
export interface DrupalNodeAttributes {
title: string;
created: string;
changed: string;
status: boolean;
sticky: boolean;
promote: boolean;
path?: { alias: string; pid: number; langcode: string };
body?: {
value: string;
format: string;
summary: string;
processed: string;
};
}
// Drupal Taxonomy Term Attributes
export interface DrupalTaxonomyTermAttributes {
name: string;
description?: { value: string; format: string; processed: string };
weight: number;
parent?: JsonApiResourceIdentifier[];
}
// Content Type Specific Interfaces
export interface ArticleNodeAttributes extends DrupalNodeAttributes {
// Article-specific fields can be added here
}
export interface PageNodeAttributes extends DrupalNodeAttributes {
// Page-specific fields can be added here
}
export interface LearningResourceNodeAttributes extends DrupalNodeAttributes {
// Learning resource-specific fields can be added here
}
// Typed Response Helpers
export type ArticleNode = JsonApiResource<ArticleNodeAttributes>;
export type PageNode = JsonApiResource<PageNodeAttributes>;
export type LearningResourceNode = JsonApiResource<LearningResourceNodeAttributes>;
export type TaxonomyTerm = JsonApiResource<DrupalTaxonomyTermAttributes>;
// Collection Response Types
export type NodeList<T extends object = Record<string, unknown>> = JsonApiDocument<T>;
export type SingleNode<T extends object = Record<string, unknown>> = JsonApiDocument<T>;
export type TaxonomyTermList = JsonApiDocument<DrupalTaxonomyTermAttributes>;

View file

@ -1,2 +1,10 @@
/// <reference types="vite/client" />
/// <reference types="unplugin-vue-router/client" />
interface ImportMetaEnv {
readonly VITE_DRUPAL_API_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}