/// /** * 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; langcode?: "en" | "vp-VL"; // Support English and Viossa translations includeUnpublished?: boolean; // New parameter for preview mode } interface NodeParams extends JsonApiParams { id: string; contentType: ContentType; include?: string[]; langcode?: "en" | "vp-VL"; includeUnpublished?: boolean; // New parameter for preview mode } interface TaxonomyParams { vocabulary: Vocabulary; langcode?: "en" | "vp-VL"; } /** * 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; private authHeaders: Record = {}; 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"; } } /** * Set authentication headers for subsequent requests */ setAuthHeaders(headers: Record) { this.authHeaders = { ...headers }; } /** * Clear authentication headers */ clearAuthHeaders() { this.authHeaders = {}; } /** * Build query string from JsonApiParams */ private buildQueryString(params: JsonApiParams = {}, langcode?: string, includeUnpublished?: boolean): string { const queryParts: string[] = []; // Language filter for translated content if (langcode) { queryParts.push(`filter[langcode]=${langcode}`); } // Include unpublished content in preview mode if (includeUnpublished) { queryParts.push(`filter[status][condition][path]=status&filter[status][condition][operator]=IS NULL&filter[status][condition][value]=1`); } // 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, 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, 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(url: string): Promise { const headers: Record = { "Accept": "application/vnd.api+json", ...this.authHeaders, }; const response = await fetch(url, { headers, credentials: 'include', // Include cookies for session auth }); if (!response.ok) { const errorBody = await response.text(); let errors: JsonApiError[] = []; try { const errorJson = JSON.parse(errorBody); errors = errorJson.errors || []; } catch { // Not JSON error } throw new DrupalApiError( `HTTP ${response.status}: ${response.statusText}`, response.status, errors ); } return response.json(); } /** * Get nodes by content type */ async getNodes( params: NodeListParams ): Promise> { const { contentType, langcode, includeUnpublished, ...queryParams } = params; const queryString = this.buildQueryString(queryParams, langcode, includeUnpublished); const url = `${this.baseUrl}/node/${contentType}${queryString}`; return this.fetchJson>(url); } /** * Get a single node by ID * Note: Drupal JSON:API uses /jsonapi/node/{type}/{uuid} for individual nodes */ async getNode( params: NodeParams ): Promise> { const { id, contentType, include, langcode, includeUnpublished } = params; const queryParams: JsonApiParams = include ? { include } : {}; const queryString = this.buildQueryString(queryParams, langcode, includeUnpublished); const url = `${this.baseUrl}/node/${contentType}/${id}${queryString}`; return this.fetchJson>(url); } /** * Get taxonomy terms by vocabulary */ async getTaxonomyTerms( params: TaxonomyParams ): Promise> { const { vocabulary, langcode } = params; const queryString = langcode ? this.buildQueryString({}, langcode) : ""; const url = `${this.baseUrl}/taxonomy_term/${vocabulary}${queryString}`; return this.fetchJson>(url); } /** * Follow pagination links */ async getNextPage(links: JsonApiLinks): Promise | 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>(url); } /** * Helper: Extract data array from document */ extractData(document: JsonApiDocument): JsonApiResource[] { return Array.isArray(document.data) ? document.data : [document.data]; } /** * Helper: Get attribute from resource */ getAttribute(resource: JsonApiResource): 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();