feat(apiclient): Add native Admin & Content API clients, JWT generator, and tag visibility features; remove external @tryghost deps and update docs
This commit is contained in:
318
ts/apiclient/ghost.adminapi.ts
Normal file
318
ts/apiclient/ghost.adminapi.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user