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="vite/client" />
|
||||||
/// <reference types="unplugin-vue-router/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