diff --git a/apps/vdn-static/.env.example b/apps/vdn-static/.env.example new file mode 100644 index 0000000..6256a2a --- /dev/null +++ b/apps/vdn-static/.env.example @@ -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 \ No newline at end of file diff --git a/apps/vdn-static/src/services/DrupalService.ts b/apps/vdn-static/src/services/DrupalService.ts new file mode 100644 index 0000000..c8f75ff --- /dev/null +++ b/apps/vdn-static/src/services/DrupalService.ts @@ -0,0 +1,256 @@ +/// +/** + * 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, 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 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( + params: NodeListParams + ): Promise> { + const { contentType, ...queryParams } = params; + const queryString = this.buildQueryString(queryParams); + 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 } = params; + const queryParams: JsonApiParams = include ? { include } : {}; + const queryString = this.buildQueryString(queryParams); + const url = `${this.baseUrl}/node/${contentType}/${id}${queryString}`; + + return this.fetchJson>(url); + } + + /** + * Get taxonomy terms by vocabulary + */ + async getTaxonomyTerms( + params: TaxonomyParams + ): Promise> { + const { vocabulary } = params; + const url = `${this.baseUrl}/taxonomy_term/${vocabulary}`; + + 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(); \ No newline at end of file diff --git a/apps/vdn-static/src/types/drupal.ts b/apps/vdn-static/src/types/drupal.ts new file mode 100644 index 0000000..d66caca --- /dev/null +++ b/apps/vdn-static/src/types/drupal.ts @@ -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> { + type: string; + id: string; + attributes: T; + relationships?: Record; + 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> { + jsonapi?: { version: string; meta?: Record }; + data: JsonApiResource | JsonApiResource[]; + included?: JsonApiResource[]; + links?: JsonApiLinks; + meta?: Record; + 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; + sort?: string[]; + page?: { limit?: number; offset?: number; cursor?: string }; + filter?: Record; +} + +// 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; +export type PageNode = JsonApiResource; +export type LearningResourceNode = JsonApiResource; + +export type TaxonomyTerm = JsonApiResource; + +// Collection Response Types +export type NodeList> = JsonApiDocument; +export type SingleNode> = JsonApiDocument; +export type TaxonomyTermList = JsonApiDocument; \ No newline at end of file diff --git a/apps/vdn-static/src/vite-env.d.ts b/apps/vdn-static/src/vite-env.d.ts index dabd0de..48fef1e 100644 --- a/apps/vdn-static/src/vite-env.d.ts +++ b/apps/vdn-static/src/vite-env.d.ts @@ -1,2 +1,10 @@ /// /// + +interface ImportMetaEnv { + readonly VITE_DRUPAL_API_URL?: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} \ No newline at end of file