319 lines
9.6 KiB
TypeScript
319 lines
9.6 KiB
TypeScript
|
/**
|
||
|
* Ghost Admin API Client
|
||
|
* Full CRUD operations for Ghost content using native fetch
|
||
|
*/
|
||
|
|
||
|
import { generateToken } from './ghost.jwt.js';
|
||
|
import type { THttpMethod, IBrowseOptions, IGhostAPIResponse, IGhostErrorResponse } from './ghost.types.js';
|
||
|
import * as fs from 'fs';
|
||
|
import * as path from 'path';
|
||
|
|
||
|
export interface IGhostAdminAPIOptions {
|
||
|
url: string;
|
||
|
key: string;
|
||
|
version?: string;
|
||
|
ghostPath?: string;
|
||
|
}
|
||
|
|
||
|
export class GhostAdminAPI {
|
||
|
private url: string;
|
||
|
private key: string;
|
||
|
private version: string;
|
||
|
private ghostPath: string;
|
||
|
private acceptVersionHeader: string;
|
||
|
|
||
|
constructor(options: IGhostAdminAPIOptions) {
|
||
|
this.url = options.url.replace(/\/$/, ''); // Remove trailing slash
|
||
|
this.key = options.key;
|
||
|
this.version = options.version || 'v3';
|
||
|
this.ghostPath = options.ghostPath || 'ghost';
|
||
|
|
||
|
if (!this.url) {
|
||
|
throw new Error('GhostAdminAPI: url is required');
|
||
|
}
|
||
|
if (!this.key) {
|
||
|
throw new Error('GhostAdminAPI: key is required');
|
||
|
}
|
||
|
|
||
|
// Set accept version header
|
||
|
if (this.version.match(/^v\d+$/)) {
|
||
|
this.acceptVersionHeader = `${this.version}.0`;
|
||
|
} else if (this.version.match(/^v\d+\.\d+$/)) {
|
||
|
this.acceptVersionHeader = this.version;
|
||
|
} else {
|
||
|
this.acceptVersionHeader = 'v6.0';
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Build the API prefix based on version
|
||
|
*/
|
||
|
private getAPIPrefix(): string {
|
||
|
// v5+ doesn't need version prefix
|
||
|
if (this.version === 'v5' || this.version === 'v6' || this.version.match(/^v[5-9]\.\d+/)) {
|
||
|
return `/admin/`;
|
||
|
}
|
||
|
// v2-v4 and canary need version prefix
|
||
|
return `/${this.version}/admin/`;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Build full API URL
|
||
|
*/
|
||
|
private buildUrl(resource: string, identifier?: string, params?: Record<string, any>): string {
|
||
|
const apiPrefix = this.getAPIPrefix();
|
||
|
let endpoint = `${this.url}/${this.ghostPath}/api${apiPrefix}${resource}/`;
|
||
|
|
||
|
if (identifier) {
|
||
|
endpoint += `${identifier}/`;
|
||
|
}
|
||
|
|
||
|
// Build query string if params exist
|
||
|
if (params && Object.keys(params).length > 0) {
|
||
|
const queryString = Object.keys(params)
|
||
|
.filter(key => params[key] !== undefined && params[key] !== null)
|
||
|
.map(key => {
|
||
|
const value = Array.isArray(params[key])
|
||
|
? params[key].join(',')
|
||
|
: params[key];
|
||
|
return `${key}=${encodeURIComponent(value)}`;
|
||
|
})
|
||
|
.join('&');
|
||
|
|
||
|
if (queryString) {
|
||
|
endpoint += `?${queryString}`;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return endpoint;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get authorization token
|
||
|
*/
|
||
|
private async getAuthToken(): Promise<string> {
|
||
|
const audience = this.getAPIPrefix();
|
||
|
return await generateToken(this.key, audience);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Make API request
|
||
|
*/
|
||
|
private async makeRequest<T>(
|
||
|
resource: string,
|
||
|
method: THttpMethod = 'GET',
|
||
|
identifier?: string,
|
||
|
body?: any,
|
||
|
queryParams?: Record<string, any>
|
||
|
): Promise<T | T[] | void> {
|
||
|
const url = this.buildUrl(resource, identifier, queryParams);
|
||
|
const token = await this.getAuthToken();
|
||
|
|
||
|
const headers: Record<string, string> = {
|
||
|
'Authorization': `Ghost ${token}`,
|
||
|
'Accept-Version': this.acceptVersionHeader,
|
||
|
'Accept': 'application/json',
|
||
|
'User-Agent': 'GhostAdminAPI/2.0'
|
||
|
};
|
||
|
|
||
|
const requestOptions: RequestInit = {
|
||
|
method,
|
||
|
headers
|
||
|
};
|
||
|
|
||
|
// Add body for POST/PUT
|
||
|
if (body && (method === 'POST' || method === 'PUT')) {
|
||
|
headers['Content-Type'] = 'application/json';
|
||
|
requestOptions.body = JSON.stringify(body);
|
||
|
}
|
||
|
|
||
|
try {
|
||
|
const response = await fetch(url, requestOptions);
|
||
|
|
||
|
if (!response.ok) {
|
||
|
const errorData: IGhostErrorResponse = await response.json().catch(() => ({
|
||
|
errors: [{ type: 'UnknownError', message: response.statusText }]
|
||
|
}));
|
||
|
|
||
|
const error = errorData.errors?.[0];
|
||
|
const err = new Error(error?.message || response.statusText);
|
||
|
Object.assign(err, {
|
||
|
name: error?.type || 'GhostAdminAPIError',
|
||
|
statusCode: response.status,
|
||
|
...error
|
||
|
});
|
||
|
throw err;
|
||
|
}
|
||
|
|
||
|
// DELETE returns empty response
|
||
|
if (method === 'DELETE') {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const data: IGhostAPIResponse<T> = await response.json();
|
||
|
|
||
|
// Extract the resource data
|
||
|
const resourceData = data[resource];
|
||
|
if (!resourceData) {
|
||
|
return data as any; // For some special endpoints that don't follow standard format
|
||
|
}
|
||
|
|
||
|
// If it's an array and has meta, attach meta to the array
|
||
|
if (Array.isArray(resourceData) && data.meta) {
|
||
|
return Object.assign(resourceData, { meta: data.meta });
|
||
|
}
|
||
|
|
||
|
// If it's an array with single item and no meta, return the item
|
||
|
if (Array.isArray(resourceData) && resourceData.length === 1 && !data.meta) {
|
||
|
return resourceData[0];
|
||
|
}
|
||
|
|
||
|
return resourceData as T | T[];
|
||
|
} catch (error) {
|
||
|
throw error;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Create resource API methods factory
|
||
|
*/
|
||
|
private createResourceAPI(resourceType: string) {
|
||
|
return {
|
||
|
browse: (options?: IBrowseOptions) => {
|
||
|
return this.makeRequest(resourceType, 'GET', undefined, undefined, options);
|
||
|
},
|
||
|
|
||
|
read: (data: { id?: string; slug?: string; email?: string }, queryParams?: Record<string, any>) => {
|
||
|
if (data.slug) {
|
||
|
return this.makeRequest(resourceType, 'GET', `slug/${data.slug}`, undefined, queryParams);
|
||
|
}
|
||
|
if (data.email) {
|
||
|
return this.makeRequest(resourceType, 'GET', `email/${data.email}`, undefined, queryParams);
|
||
|
}
|
||
|
if (data.id) {
|
||
|
return this.makeRequest(resourceType, 'GET', data.id, undefined, queryParams);
|
||
|
}
|
||
|
throw new Error('Must provide id, slug, or email');
|
||
|
},
|
||
|
|
||
|
add: (data: any, queryParams?: Record<string, any>) => {
|
||
|
if (!data || !Object.keys(data).length) {
|
||
|
return Promise.reject(new Error('Missing data'));
|
||
|
}
|
||
|
const body: any = {};
|
||
|
body[resourceType] = [data];
|
||
|
return this.makeRequest(resourceType, 'POST', undefined, body, queryParams);
|
||
|
},
|
||
|
|
||
|
edit: (data: any, queryParams?: Record<string, any>) => {
|
||
|
if (!data) {
|
||
|
return Promise.reject(new Error('Missing data'));
|
||
|
}
|
||
|
if (!data.id) {
|
||
|
return Promise.reject(new Error('Must include data.id'));
|
||
|
}
|
||
|
|
||
|
const id = data.id;
|
||
|
const updateData = { ...data };
|
||
|
delete updateData.id;
|
||
|
|
||
|
const body: any = {};
|
||
|
body[resourceType] = [updateData];
|
||
|
|
||
|
return this.makeRequest(resourceType, 'PUT', id, body, queryParams);
|
||
|
},
|
||
|
|
||
|
delete: (data: { id?: string; email?: string }, queryParams?: Record<string, any>) => {
|
||
|
if (!data) {
|
||
|
return Promise.reject(new Error('Missing data'));
|
||
|
}
|
||
|
if (!data.id && !data.email) {
|
||
|
return Promise.reject(new Error('Must include either data.id or data.email'));
|
||
|
}
|
||
|
|
||
|
const identifier = data.email ? `email/${data.email}` : data.id;
|
||
|
return this.makeRequest(resourceType, 'DELETE', identifier, undefined, queryParams);
|
||
|
}
|
||
|
};
|
||
|
}
|
||
|
|
||
|
// Resource APIs
|
||
|
public posts = this.createResourceAPI('posts');
|
||
|
public pages = this.createResourceAPI('pages');
|
||
|
public tags = this.createResourceAPI('tags');
|
||
|
public members = this.createResourceAPI('members');
|
||
|
public users = this.createResourceAPI('users');
|
||
|
|
||
|
// Webhooks (limited operations)
|
||
|
public webhooks = {
|
||
|
add: (data: any, queryParams?: Record<string, any>) => {
|
||
|
const body: any = { webhooks: [data] };
|
||
|
return this.makeRequest('webhooks', 'POST', undefined, body, queryParams);
|
||
|
},
|
||
|
|
||
|
edit: (data: any, queryParams?: Record<string, any>) => {
|
||
|
if (!data.id) {
|
||
|
return Promise.reject(new Error('Must include data.id'));
|
||
|
}
|
||
|
const id = data.id;
|
||
|
const updateData = { ...data };
|
||
|
delete updateData.id;
|
||
|
const body: any = { webhooks: [updateData] };
|
||
|
return this.makeRequest('webhooks', 'PUT', id, body, queryParams);
|
||
|
},
|
||
|
|
||
|
delete: (data: { id: string }, queryParams?: Record<string, any>) => {
|
||
|
if (!data.id) {
|
||
|
return Promise.reject(new Error('Must include data.id'));
|
||
|
}
|
||
|
return this.makeRequest('webhooks', 'DELETE', data.id, undefined, queryParams);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// Image upload
|
||
|
public images = {
|
||
|
upload: async (data: { file: string }) => {
|
||
|
if (!data || !data.file) {
|
||
|
throw new Error('Must provide file path');
|
||
|
}
|
||
|
|
||
|
const url = this.buildUrl('images', 'upload');
|
||
|
const token = await this.getAuthToken();
|
||
|
|
||
|
// Read file
|
||
|
const fileBuffer = fs.readFileSync(data.file);
|
||
|
const fileName = path.basename(data.file);
|
||
|
|
||
|
// Create FormData
|
||
|
const formData = new FormData();
|
||
|
// Convert Buffer to ArrayBuffer for Blob
|
||
|
const arrayBuffer = fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.byteLength) as ArrayBuffer;
|
||
|
const blob = new Blob([arrayBuffer], { type: 'image/*' });
|
||
|
formData.append('file', blob, fileName);
|
||
|
|
||
|
const response = await fetch(url, {
|
||
|
method: 'POST',
|
||
|
headers: {
|
||
|
'Authorization': `Ghost ${token}`,
|
||
|
'Accept-Version': this.acceptVersionHeader,
|
||
|
'User-Agent': 'GhostAdminAPI/2.0'
|
||
|
},
|
||
|
body: formData
|
||
|
});
|
||
|
|
||
|
if (!response.ok) {
|
||
|
const errorData: IGhostErrorResponse = await response.json().catch(() => ({
|
||
|
errors: [{ type: 'UnknownError', message: response.statusText }]
|
||
|
}));
|
||
|
|
||
|
const error = errorData.errors?.[0];
|
||
|
const err = new Error(error?.message || response.statusText);
|
||
|
throw err;
|
||
|
}
|
||
|
|
||
|
const result = await response.json();
|
||
|
return result.images?.[0] || result;
|
||
|
}
|
||
|
};
|
||
|
}
|