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
6
apps/vdn-static/.env.example
Normal file
6
apps/vdn-static/.env.example
Normal 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
|
||||
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();
|
||||
115
apps/vdn-static/src/types/drupal.ts
Normal file
115
apps/vdn-static/src/types/drupal.ts
Normal 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>;
|
||||
8
apps/vdn-static/src/vite-env.d.ts
vendored
8
apps/vdn-static/src/vite-env.d.ts
vendored
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue