astro-ghostcms/.pnpm-store/v3/files/60/66d2d2c15de434c499ee30e7a94...

588 lines
18 KiB
Plaintext
Raw Normal View History

2024-02-14 14:10:47 +00:00
<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>
[![tests](https://github.com/PhilDL/ts-ghost/actions/workflows/deploy.yml/badge.svg)](https://github.com/PhilDL/ts-ghost/actions/workflows/deploy.yml) ![License](https://img.shields.io/github/license/PhilDL/ts-ghost) <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.