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:
parent
ee2a9cd2ee
commit
68792b9cf5
4 changed files with 385 additions and 0 deletions
256
apps/vdn-static/src/services/DrupalService.ts
Normal file
256
apps/vdn-static/src/services/DrupalService.ts
Normal 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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue