Introduction
This is an internal package, containing the bulding blocks of the Content and Admin packages. Unless you want to build your own implementation you probably don't need this package.
Documentation here may be outdated, this package being internal, it is changing a lot through versions to versions... still under construction 🏗️
@ts-ghost/core-api contains the core building blocks for the @ts-ghost/content-api package. It contains the Type-safe logic of Query Builder and Fetchers. Unless you are building a new package for @ts-ghost you should not need to use this package directly.
Install
pnpm i @ts-ghost/core-apipnpm i @ts-ghost/core-apiRequirements
This client is only compatible with Ghost versions 5.x for now.
-
Ghost 5^
-
Node.js 16+
- We rely on global
fetchbeing available, so you can bring your own polyfill and if you run Node 16, you'll need to run with the--experimental-fetchflag enabled.
- We rely on global
APIComposer
The APIComposer is a class that helps you build the target API with the available methods for a resource based on a combinations of ZodSchema. This APIComposer exposes 5 methods:
readto fetch a single record andbrowseto fetch multiple records.addto create a record.editto update a record.deleteto delete a record.
All these methods like read and browse gives you back the appropriate Fetcher instance that will handle the actual request to the API with the correct parameters.
APIComposer will handle type-safety of the query parameters, will return the appropriate fetcher and will pass along the correct output type based on the ZodSchema you instantiate it with. For the query methods like browse and read, this output schema will be modified if required when you select specific fields, includes etc.
Instantiation
import { z } from "zod";
import { APIComposer, HTTPClient, type HTTPClientOptions } from "@ts-ghost/core-api";
const credentials: HTTPClientOptions = {
url: "https://ghost.org",
key: "7d2d15d7338526d43c2fadc47c",
version: "v5.0",
endpoint: "content",
};
const httpClient = new HTTPClient(credentials);
const simplifiedSchema = z.object({
title: z.string(),
slug: z.string(),
count: z.number().optional(),
});
// the "identity" schema is used to validate the inputs of the `read`method of the APIComposer
const identitySchema = z.union([z.object({ slug: z.string() }), z.object({ id: z.string() })]);
// the "include" schema is used to validate the "include" parameters of the API call
// it is specific to the Ghost API resource targeted.
// The format is always { 'name_of_the_field': true }
const simplifiedIncludeSchema = z.object({
count: z.literal(true).optional(),
});
const createSchema = z.object({
foo: z.string(),
bar: z.string().nullish(),
baz: z.boolean().nullish(),
});
const composedAPI = new APIComposer(
"posts",
{
schema: simplifiedSchema,
identitySchema: identitySchema,
include: simplifiedIncludeSchema,
createSchema: createSchema,
createOptionsSchema: z.object({
option_1: z.boolean(),
}),
},
HTTPClient
);import { z } from "zod";
import { APIComposer, HTTPClient, type HTTPClientOptions } from "@ts-ghost/core-api";
const credentials: HTTPClientOptions = {
url: "https://ghost.org",
key: "7d2d15d7338526d43c2fadc47c",
version: "v5.0",
endpoint: "content",
};
const httpClient = new HTTPClient(credentials);
const simplifiedSchema = z.object({
title: z.string(),
slug: z.string(),
count: z.number().optional(),
});
// the "identity" schema is used to validate the inputs of the `read`method of the APIComposer
const identitySchema = z.union([z.object({ slug: z.string() }), z.object({ id: z.string() })]);
// the "include" schema is used to validate the "include" parameters of the API call
// it is specific to the Ghost API resource targeted.
// The format is always { 'name_of_the_field': true }
const simplifiedIncludeSchema = z.object({
count: z.literal(true).optional(),
});
const createSchema = z.object({
foo: z.string(),
bar: z.string().nullish(),
baz: z.boolean().nullish(),
});
const composedAPI = new APIComposer(
"posts",
{
schema: simplifiedSchema,
identitySchema: identitySchema,
include: simplifiedIncludeSchema,
createSchema: createSchema,
createOptionsSchema: z.object({
option_1: z.boolean(),
}),
},
HTTPClient
);- first argument is the name of the resource you want to target.
- second argument is an object containing the different schemas to validate the inputs and outputs of the APIComposer:
identitySchemacan be anyZodTypeand can also be an emptyz.object({})if you don't need thereadmethod.includeis aZodObjectthat will validate theincludeparameters of the API call. It is specific to the Ghost API resource targeted. The format is always{ 'name_of_the_field': true }createSchema(Optional) is a Zod Schema that will validate the input of theaddmethod of the APIComposer.addwill take exactly the schema to parse
createOptionsSchema(Optional) is a Zod Schema that will validate options that are going to be passed as query parameters to thePOSTurl.updateSchema(Optional) is a Zod Schema that will validate the input of theeditmethod of the APIComposer.editwill fallback to aZodPartial(all fields are optional) version of thecreateSchemaifupdateSchemais not provided.
- last argument is the
HTTPClientinstance that will be used to do the actualfetchand handle the JWT if using the admin endpoint.
Building Queries
After instantiation you can use the APIComposer to build your queries with 2 available methods.
The browse and read methods accept a config object with 2 properties: input and an output. These params mimic the way Ghost API Content is built but with the power of Zod and TypeScript they are type-safe here.
import { z } from "zod";
import { APIComposer, HTTPClient, type HTTPClientOptions } from "@ts-ghost/core-api";
const credentials: HTTPClientOptions = {
url: "https://ghost.org",
key: "7d2d15d7338526d43c2fadc47c",
version: "v5.0",
endpoint: "content",
};
const httpClient = new HTTPClient(credentials);
const simplifiedSchema = z.object({
title: z.string(),
slug: z.string(),
count: z.number().optional(),
});
const identitySchema = z.union([z.object({ slug: z.string() }), z.object({ id: z.string() })]);
const simplifiedIncludeSchema = z.object({
count: z.literal(true).optional(),
});
const composedAPI = new APIComposer(
"posts",
{ schema: simplifiedSchema, identitySchema: identitySchema, include: simplifiedIncludeSchema },
httpClient
);
let query = composedAPI.browse({
limit: 5,
order: "title DESC",
// ^? the text here will throw a TypeScript lint error if you use unknown field.
// In that case `title` is correctly defined in the `simplifiedSchema
});import { z } from "zod";
import { APIComposer, HTTPClient, type HTTPClientOptions } from "@ts-ghost/core-api";
const credentials: HTTPClientOptions = {
url: "https://ghost.org",
key: "7d2d15d7338526d43c2fadc47c",
version: "v5.0",
endpoint: "content",
};
const httpClient = new HTTPClient(credentials);
const simplifiedSchema = z.object({
title: z.string(),
slug: z.string(),
count: z.number().optional(),
});
const identitySchema = z.union([z.object({ slug: z.string() }), z.object({ id: z.string() })]);
const simplifiedIncludeSchema = z.object({
count: z.literal(true).optional(),
});
const composedAPI = new APIComposer(
"posts",
{ schema: simplifiedSchema, identitySchema: identitySchema, include: simplifiedIncludeSchema },
httpClient
);
let query = composedAPI.browse({
limit: 5,
order: "title DESC",
// ^? the text here will throw a TypeScript lint error if you use unknown field.
// In that case `title` is correctly defined in the `simplifiedSchema
});- browse parameters are
page,limit,order,filter. And read parameters areidorslug.
Method options
.browse options
Input are totally optionals on the browse method but they let you filter and order your search.
This is an example containing all the available keys in the input object
const composedAPI = new APIComposer(
"posts",
{ schema: simplifiedSchema, identitySchema: identitySchema, include: simplifiedIncludeSchema },
httpClient
);
let query = composedAPI.browse({
page: 1,
limit: 5,
filter: "title:typescript+slug:-test",
order: "title DESC",
});const composedAPI = new APIComposer(
"posts",
{ schema: simplifiedSchema, identitySchema: identitySchema, include: simplifiedIncludeSchema },
httpClient
);
let query = composedAPI.browse({
page: 1,
limit: 5,
filter: "title:typescript+slug:-test",
order: "title DESC",
});These browse params are then parsed through a Zod Schema that will validate all the fields.
page:numberThe current page requestedlimit:numberBetween 0 and 15 (limitation of the Ghost API)filter:stringContains the filter with Ghost APIfiltersyntax.order:stringContains the name of the field and the orderASCorDESC.
For the order and filter if you use fields that are not present on the schema (for example name on a Post) then the APIComposer will throw an Error with message containing the unknown field.
.read options
Read is meant to be used to fetch 1 object only by id or slug.
const composedAPI = new APIComposer(
"posts",
{ schema: simplifiedSchema, identitySchema: identitySchema, include: simplifiedIncludeSchema },
httpClient
);
let query = composedAPI.read({
id: "edHks74hdKqhs34izzahd45"
});
// or
let query = composedAPI.read({
slug: "typescript-is-awesome-in-2025"
});const composedAPI = new APIComposer(
"posts",
{ schema: simplifiedSchema, identitySchema: identitySchema, include: simplifiedIncludeSchema },
httpClient
);
let query = composedAPI.read({
id: "edHks74hdKqhs34izzahd45"
});
// or
let query = composedAPI.read({
slug: "typescript-is-awesome-in-2025"
});You can submit both id and slug, but the fetcher will then chose the id in priority if present to make the final URL query to the Ghost API.
Query Fetchers
If the parsing went okay, the read and browse methods from the APIComposer will return the associated Fetcher.
BrowseFetcherfor thebrowsemethodReadFetcherfor thereadmethodBasicFetcheris a special case when you don't need a APIComposer at all and want to fetch directly.
Fetchers are instatiated automatically after using read or browse but these Fetchers can also be instantiated in isolation, in a similar way as the APIComposer with a config containing the same schemas. But also a set of params
necessary to build the URL to the Ghost API.
import { BrowseFetcher, HTTPClient } from "@ts-ghost/core-api";
const httpClient = new HTTPClient({
url: "https://ghost.org",
key: "7d2d15d7338526d43c2fadc47c",
version: "v5.0",
endpoint: "content",
});
// Example of instantiating a Fetcher, even though you will probably not do it
const browseFetcher = new BrowseFetcher(
"posts",
{
schema: simplifiedSchema,
output: simplifiedSchema,
include: simplifiedIncludeSchema,
},
{
browseParams: {
limit: 1,
},
},
httpClient
);import { BrowseFetcher, HTTPClient } from "@ts-ghost/core-api";
const httpClient = new HTTPClient({
url: "https://ghost.org",
key: "7d2d15d7338526d43c2fadc47c",
version: "v5.0",
endpoint: "content",
});
// Example of instantiating a Fetcher, even though you will probably not do it
const browseFetcher = new BrowseFetcher(
"posts",
{
schema: simplifiedSchema,
output: simplifiedSchema,
include: simplifiedIncludeSchema,
},
{
browseParams: {
limit: 1,
},
},
httpClient
);The option output schema will be modified along the way after the params like fields, formats, include are added to the query. At instantiation it will most likely be the same as the original schema.
These fetchers have a fetch method that will return a discriminated union of 2 types:
const composedAPI = new APIComposer(
"posts",
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema },
httpClient
);
const readFetcher = composedAPI.read({ slug: "typescript-is-cool" });
let result = await readFetcher.fetch();
if (result.success) {
const post = result.data;
// ^? type {"slug":string; "title": string}
} else {
// errors array of objects
console.log(result.errors.map((e) => e.message).join("\n"));
}const composedAPI = new APIComposer(
"posts",
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema },
httpClient
);
const readFetcher = composedAPI.read({ slug: "typescript-is-cool" });
let result = await readFetcher.fetch();
if (result.success) {
const post = result.data;
// ^? type {"slug":string; "title": string}
} else {
// errors array of objects
console.log(result.errors.map((e) => e.message).join("\n"));
}Read Fetcher
After using .read query, you will get a ReadFetcher with an async fetch method giving you a discriminated union of 2 types:
// example for the read query (the data is an object)
const result: {
status: true;
data: z.infer<typeof simplifiedSchema>; // parsed by the Zod Schema and modified by the fields selected
} | {
status: false;
errors: {
message: string;
type: string;
}[];
}// example for the read query (the data is an object)
const result: {
status: true;
data: z.infer<typeof simplifiedSchema>; // parsed by the Zod Schema and modified by the fields selected
} | {
status: false;
errors: {
message: string;
type: string;
}[];
}Browse Fetcher
After using .read query, you will get a BrowseFetcher with 2 methods:
async fetchasync paginate
Browse .fetch()
That result is a discriminated union of 2 types:
// example for the browse query (the data is an array of objects)
const result: {
success: true;
data: z.infer<typeof simplifiedSchema>[];
meta: {
pagination: {
pages: number;
limit: number;
page: number;
total: number;
prev: number | null;
next: number | null;
};
};
} | {
success: false;
errors: {
message: string;
type: string;
}[];
}// example for the browse query (the data is an array of objects)
const result: {
success: true;
data: z.infer<typeof simplifiedSchema>[];
meta: {
pagination: {
pages: number;
limit: number;
page: number;
total: number;
prev: number | null;
next: number | null;
};
};
} | {
success: false;
errors: {
message: string;
type: string;
}[];
}Browse .paginate()
const result: {
success: true;
data: z.infer<typeof simplifiedSchema>[];
meta: {
pagination: {
pages: number;
limit: number;
page: number;
total: number;
prev: number | null;
next: number | null;
};
};
next: BrowseFetcher | undefined; // the next page fetcher if it is defined
} | {
success: false;
errors: {
message: string;
type: string;
}[];
next: undefined; // the next page fetcher is undefined here
}const result: {
success: true;
data: z.infer<typeof simplifiedSchema>[];
meta: {
pagination: {
pages: number;
limit: number;
page: number;
total: number;
prev: number | null;
next: number | null;
};
};
next: BrowseFetcher | undefined; // the next page fetcher if it is defined
} | {
success: false;
errors: {
message: string;
type: string;
}[];
next: undefined; // the next page fetcher is undefined here
}Here you can use the next property to get the next page fetcher if it is defined.
Modifiying Fetchers output by selecting fields, formats, include
Output can be modified on the BrowseFetcher and the ReadFetcher through available methods:
.fields.formats.include
.fields()
The fields methods lets you change the output of the result to have only your selected fields, it works by giving the property key and the value true to the field you want to keep. Under the hood it will use the zod.pick method to pick only the fields you want.
import { BrowseFetcher, HTTPClient } from "@ts-ghost/core-api";
const httpClient = new HTTPClient({
url: "https://ghost.org",
key: "7d2d15d7338526d43c2fadc47c",
version: "v5.0",
endpoint: "content",
});
// Example of instantiating a Fetcher, even though you will probably not do it
const browseFetcher = new BrowseFetcher(
"members",
{
schema: simplifiedSchema,
output: simplifiedSchema,
include: simplifiedIncludeSchema,
},
{
browseParams: {
limit: 1,
},
},
httpClient
);
let result = await browseFetcher
.fields({
slug: true,
title: true,
// ^? available fields come form the `simplifiedSchema` passed in the constructor
})
.fetch();
if (result.success) {
const post = result.data;
// ^? type {"slug":string; "title": string}
}import { BrowseFetcher, HTTPClient } from "@ts-ghost/core-api";
const httpClient = new HTTPClient({
url: "https://ghost.org",
key: "7d2d15d7338526d43c2fadc47c",
version: "v5.0",
endpoint: "content",
});
// Example of instantiating a Fetcher, even though you will probably not do it
const browseFetcher = new BrowseFetcher(
"members",
{
schema: simplifiedSchema,
output: simplifiedSchema,
include: simplifiedIncludeSchema,
},
{
browseParams: {
limit: 1,
},
},
httpClient
);
let result = await browseFetcher
.fields({
slug: true,
title: true,
// ^? available fields come form the `simplifiedSchema` passed in the constructor
})
.fetch();
if (result.success) {
const post = result.data;
// ^? type {"slug":string; "title": string}
}The output schema will be modified to only have the fields you selected and TypeScript will pick up on that to warn you if you access non-existing fields.
include
The include method lets you include some additionnal data that the Ghost API doesn't give you by default. This include key is specific to each resource and is defined in the Schema of the resource. You will have to let TypeScript guide you to know what you can include.
const bf = new BrowseFetcher(
"posts",
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema },
{},
httpClient
);
let result = await bf
.include({
count: true,
})
.fetch();const bf = new BrowseFetcher(
"posts",
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema },
{},
httpClient
);
let result = await bf
.include({
count: true,
})
.fetch();The output type will be modified to make the fields you include non-optionals.
formats
The formats method lets you include some additionnal formats that the Ghost API doesn't give you by default. This is used on the Post and Page resource to retrieve the content in plaintext, html, or mobiledoc format. The available keys are html | mobiledoc | plaintext and the value is a boolean to indicate if you want to include it or not.
const bf = new BrowseFetcher(
"posts",
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema },
{},
httpClient
);
let result = await bf
.formats({
html: true,
plaintext: true,
})
.fetch();const bf = new BrowseFetcher(
"posts",
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema },
{},
httpClient
);
let result = await bf
.formats({
html: true,
plaintext: true,
})
.fetch();The output type will be modified to make the fields html and plaintext non-optionals.
Chaining methods
You can chain the methods to select the fields, formats, and include you want.
const bf = new BrowseFetcher(
"posts",
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema },
{},
httpClient
);
let result = await bf
.fields({
slug: true,
title: true,
html: true,
plaintext: true,
count: true,
})
.formats({
html: true,
plaintext: true,
})
.include({
count: true,
})
.fetch();const bf = new BrowseFetcher(
"posts",
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema },
{},
httpClient
);
let result = await bf
.fields({
slug: true,
title: true,
html: true,
plaintext: true,
count: true,
})
.formats({
html: true,
plaintext: true,
})
.include({
count: true,
})
.fetch();fetch options
You can pass an optional options object to the fetch and paginate method. The options object is the standard RequestInit object from the fetch API.
let result = await api.posts.read({ slug: "typescript-is-cool" }).fetch({ cache: "no-store" });let result = await api.posts.read({ slug: "typescript-is-cool" }).fetch({ cache: "no-store" });This may be useful if you use NextJS augmented fetch!
Mutations
These mutations are async methods, they will return a Promise that will resolve to the parsed result.
Create record
const composedAPI = new APIComposer(
"posts",
{
schema: simplifiedSchema,
identitySchema: identitySchema,
include: simplifiedIncludeSchema,
createSchema: createSchema,
createOptionsSchema: z.object({
option_1: z.boolean(),
}),
},
httpClient
);
let newPost = await composedAPI.add(
{
title: "My new post",
},
{
option_1: true,
}
);const composedAPI = new APIComposer(
"posts",
{
schema: simplifiedSchema,
identitySchema: identitySchema,
include: simplifiedIncludeSchema,
createSchema: createSchema,
createOptionsSchema: z.object({
option_1: z.boolean(),
}),
},
httpClient
);
let newPost = await composedAPI.add(
{
title: "My new post",
},
{
option_1: true,
}
);- The first argument is the
inputobject that will be parsed and typed with thecreateSchemaschema. - The second argument is the
optionsobject that will be parsed and typed with thecreateOptionsSchemaschema.
The result will be parsed and typed with the output schema and represent the newly created record.
// return from the `add` method
const result: {
success: true;
data: z.infer<typeof simplifiedSchema>; // parsed by the Zod Schema given in the config
} | {
success: false;
errors: {
message: string;
type: string;
}[];
}// return from the `add` method
const result: {
success: true;
data: z.infer<typeof simplifiedSchema>; // parsed by the Zod Schema given in the config
} | {
success: false;
errors: {
message: string;
type: string;
}[];
}Edit record
Edit requires the id of the record to edit.
let newPost = await composedAPI.edit("edHks74hdKqhs34izzahd45", {
title: "My new post",
});let newPost = await composedAPI.edit("edHks74hdKqhs34izzahd45", {
title: "My new post",
});The result will be parsed and typed with the output schema and represent the updated record.
- The first argument is the
idof the record to edit. - The second argument is the
inputobject that will be parsed and typed with thecreateSchemaschema wrapped with Partial. So all fields are optional.
// return from the `edit` method
const result: {
success: true;
data: z.infer<typeof simplifiedSchema>; // parsed by the Zod Schema given in the config
} | {
success: false;
errors: {
message: string;
type: string;
}[];
}// return from the `edit` method
const result: {
success: true;
data: z.infer<typeof simplifiedSchema>; // parsed by the Zod Schema given in the config
} | {
success: false;
errors: {
message: string;
type: string;
}[];
}Delete record
Delete requires the id of the record to delete.
let newPost = await composedAPI.edit("edHks74hdKqhs34izzahd45", {
title: "My new post",
});let newPost = await composedAPI.edit("edHks74hdKqhs34izzahd45", {
title: "My new post",
});- The first argument is the
idof the record to delete.
The response will not contain any data since Ghost API just return a 204 empty response. You will have to check the discriminator success to know if the deletion was successful or not.
// return from the `delete` method
const result: {
success: true;
} | {
success: false;
errors: {
message: string;
type: string;
}[];
}// return from the `delete` method
const result: {
success: true;
} | {
success: false;
errors: {
message: string;
type: string;
}[];
}Roadmap
- Handling POST, PUT and DELETE requests.
- Writing examples documentation for mutations.
Contributing
Contributions are what make the open source community such an amazing place to be learn, inspire, and create. Any contributions you make are greatly appreciated.
- If you have suggestions for adding or removing projects, feel free to open an issue to discuss it, or directly create a pull request after you edit the README.md file with necessary changes.
- Please make sure you check your spelling and grammar.
- Create individual PR for each suggestion.
- Please also read through the Code Of Conduct before posting your first idea as well.
License
Distributed under the MIT License. See LICENSE for more information.
Authors
- PhilDL - Creator