588 lines
18 KiB
Plaintext
588 lines
18 KiB
Plaintext
<br/>
|
|
<br/>
|
|
|
|
<div align="center">
|
|
<a href="https://github.com/PhilDL/ts-ghost">
|
|
<img src="https://user-images.githubusercontent.com/4941205/221607740-28ce02cb-da96-4e34-a40d-8163bb7c668f.png" alt="Logo" width="auto" height="80">
|
|
</a>
|
|
|
|
<h3 align="center"><code>@ts-ghost/core-api</code></h3>
|
|
|
|
<p align="center">
|
|
<code>@ts-ghost/core-api</code> is a building block used by the `@ts-ghost/content-api` it contains the Type-safe logic of Query Builder and Fetchers.
|
|
<br/>
|
|
<br/>
|
|
</p>
|
|
</div>
|
|
|
|
[](https://github.com/PhilDL/ts-ghost/actions/workflows/deploy.yml)  <img alt="GitHub package.json version (subfolder of monorepo)" src="https://img.shields.io/github/package-json/v/PhilDL/ts-ghost?filename=packages%2Fts-ghost-core-api%2Fpackage.json">
|
|
|
|
## About The Project
|
|
|
|
`@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
|
|
|
|
```shell
|
|
pnpm i @ts-ghost/core-api
|
|
```
|
|
|
|
### Requirements
|
|
|
|
This client is only compatible with Ghost versions 5.x for now.
|
|
|
|
- Ghost 5^
|
|
|
|
- Node.js 16+
|
|
- We rely on global `fetch` being available, so you can bring your own
|
|
polyfill and if you run Node 16, you'll need to run with the
|
|
`--experimental-fetch` flag enabled.
|
|
|
|
## APIComposer
|
|
|
|
The APIComposer is a class that helps you build the target API avec the available methods for a resource based on a combinations of ZodSchema. This APIComposer exposes 5 methods:
|
|
|
|
- `read` to fetch a single record and
|
|
- `browse` to fetch multiple records.
|
|
- `add` to create a record.
|
|
- `edit` to update a record.
|
|
- `delete` to 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 and 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
|
|
|
|
```ts
|
|
import { z } from "zod";
|
|
import { APIComposer, type ContentAPICredentials } from "@ts-ghost/core-api";
|
|
|
|
const api: ContentAPICredentials = {
|
|
url: "https://ghost.org",
|
|
key: "7d2d15d7338526d43c2fadc47c",
|
|
version: "v5.0",
|
|
resource: "posts",
|
|
};
|
|
|
|
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(
|
|
{
|
|
schema: simplifiedSchema,
|
|
identitySchema: identitySchema,
|
|
include: simplifiedIncludeSchema,
|
|
createSchema: createSchema,
|
|
createOptionsSchema: z.object({
|
|
option_1: z.boolean(),
|
|
}),
|
|
},
|
|
api
|
|
);
|
|
```
|
|
|
|
- `identitySchema` can be any `ZodType` and can also be an empty `z.object({})` if you don't need the `read` method.
|
|
- `include` is a `ZodObject` that will 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 }`
|
|
- `createSchema` (Optional) is a Zod Schema that will validate the input of the `add` method of the APIComposer.
|
|
- `add` will 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 the `POST` url.
|
|
- `updateSchema` (Optional) is a Zod Schema that will validate the input of the `edit` method of the APIComposer.
|
|
- `edit` will fallback to a `ZodPartial` (all fields are optional) version of the `createSchema` if `updateSchema` is not provided.
|
|
|
|
### 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.
|
|
|
|
```ts
|
|
import { z } from "zod";
|
|
import { APIComposer, type ContentAPICredentials } from "@ts-ghost/core-api";
|
|
|
|
const api: ContentAPICredentials = {
|
|
url: "https://ghost.org",
|
|
key: "7d2d15d7338526d43c2fadc47c",
|
|
version: "v5.0",
|
|
resource: "posts",
|
|
};
|
|
|
|
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(
|
|
{ schema: simplifiedSchema, identitySchema: identitySchema, include: simplifiedIncludeSchema },
|
|
api
|
|
);
|
|
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 are `id` or `slug`.
|
|
|
|
#### 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
|
|
|
|
```ts
|
|
const composedAPI = new APIComposer(
|
|
{ schema: simplifiedSchema, identitySchema: identitySchema, include: simplifiedIncludeSchema },
|
|
api
|
|
);
|
|
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:number` The current page requested
|
|
- `limit:number` Between 0 and 15 (limitation of the Ghost API)
|
|
- `filter:string` Contains the filter with [Ghost API `filter` syntax](https://ghost.org/docs/content-api/#filtering).
|
|
- `order:string` Contains the name of the field and the order `ASC` or `DESC`.
|
|
|
|
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`.
|
|
|
|
```ts
|
|
const composedAPI = new APIComposer(
|
|
{ schema: simplifiedSchema, identitySchema: identitySchema, include: simplifiedIncludeSchema },
|
|
api
|
|
);
|
|
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`.
|
|
|
|
- `BrowseFetcher` for the `browse` method
|
|
- `ReadFetcher` for the `read` method
|
|
- `BasicFetcher` is 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.
|
|
|
|
```ts
|
|
import { BrowseFetcher } from "@ts-ghost/core-api";
|
|
|
|
// Example of instantiating a Fetcher, even though you will probably not do it
|
|
const browseFetcher = new BrowseFetcher(
|
|
{
|
|
schema: simplifiedSchema,
|
|
output: simplifiedSchema,
|
|
include: simplifiedIncludeSchema,
|
|
},
|
|
{
|
|
browseParams: {
|
|
limit: 1,
|
|
},
|
|
},
|
|
api
|
|
);
|
|
```
|
|
|
|
_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:
|
|
|
|
```ts
|
|
const composedAPI = new APIComposer(
|
|
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema },
|
|
api
|
|
);
|
|
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:
|
|
|
|
```ts
|
|
// 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 fetch`
|
|
- `async paginate`
|
|
|
|
#### Browse `.fetch()`
|
|
|
|
That result is a discriminated union of 2 types:
|
|
|
|
```ts
|
|
// 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()`
|
|
|
|
```ts
|
|
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.
|
|
|
|
```ts
|
|
import { BrowseFetcher } from "@ts-ghost/core-api";
|
|
|
|
// Example of instantiating a Fetcher, even though you will probably not do it
|
|
const browseFetcher = new BrowseFetcher(
|
|
{
|
|
schema: simplifiedSchema,
|
|
output: simplifiedSchema,
|
|
include: simplifiedIncludeSchema,
|
|
},
|
|
{
|
|
browseParams: {
|
|
limit: 1,
|
|
},
|
|
},
|
|
api
|
|
);
|
|
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.
|
|
|
|
```ts
|
|
const bf = new BrowseFetcher(
|
|
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema },
|
|
{},
|
|
api
|
|
);
|
|
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.
|
|
|
|
```ts
|
|
const bf = new BrowseFetcher(
|
|
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema },
|
|
{},
|
|
api
|
|
);
|
|
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.
|
|
|
|
```ts
|
|
const bf = new BrowseFetcher(
|
|
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema },
|
|
{},
|
|
api
|
|
);
|
|
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.
|
|
|
|
```ts
|
|
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
|
|
|
|
```ts
|
|
const composedAPI = new APIComposer(
|
|
{
|
|
schema: simplifiedSchema,
|
|
identitySchema: identitySchema,
|
|
include: simplifiedIncludeSchema,
|
|
createSchema: createSchema,
|
|
createOptionsSchema: z.object({
|
|
option_1: z.boolean(),
|
|
}),
|
|
},
|
|
api
|
|
);
|
|
let newPost = await composedAPI.add(
|
|
{
|
|
title: "My new post",
|
|
},
|
|
{
|
|
option_1: true,
|
|
}
|
|
);
|
|
```
|
|
|
|
- The first argument is the `input` object that will be parsed and typed with the `createSchema` schema.
|
|
- The second argument is the `options` object that will be parsed and typed with the `createOptionsSchema` schema.
|
|
|
|
The result will be parsed and typed with the `output` schema and represent the newly created record.
|
|
|
|
```ts
|
|
// 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.
|
|
|
|
```ts
|
|
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 `id` of the record to edit.
|
|
- The second argument is the `input` object that will be parsed and typed with the `createSchema` schema wrapped with Partial. So all fields are optional.
|
|
|
|
```ts
|
|
// 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.
|
|
|
|
```ts
|
|
let newPost = await composedAPI.edit("edHks74hdKqhs34izzahd45", {
|
|
title: "My new post",
|
|
});
|
|
```
|
|
|
|
- The first argument is the `id` of 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.
|
|
|
|
```ts
|
|
// return from the `delete` method
|
|
const result: {
|
|
success: true;
|
|
} | {
|
|
success: false;
|
|
errors: {
|
|
message: string;
|
|
type: string;
|
|
}[];
|
|
}
|
|
```
|
|
|
|
## Roadmap
|
|
|
|
- [x] Handling POST, PUT and DELETE requests.
|
|
- [x] 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](https://github.com/PhilDL/ts-ghost/issues/new) 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](https://github.com/PhilDL/ts-ghost/blob/main/CODE_OF_CONDUCT.md) before posting your first idea as well.
|
|
|
|
## License
|
|
|
|
Distributed under the MIT License. See [LICENSE](https://github.com/PhilDL/ts-ghost/blob/main/LICENSE.md) for more information.
|
|
|
|
## Authors
|
|
|
|
- **[PhilDL](https://github.com/PhilDL)** - _Creator_
|
|
|
|
## Acknowledgements
|
|
|
|
- [Ghost](https://ghost.org/) is the best platform for blogging 💖 and have a good JS Client library that was a real inspiration.
|
|
- [Zod](https://github.com/colinhacks/zod) is a TypeScript-first library for data validation and schema building.
|