@ts-ghost/core-api
Advanced tools
Comparing version 0.0.7 to 0.1.0
@@ -262,3 +262,3 @@ "use strict"; | ||
getIncludes() { | ||
return this._includeFields; | ||
return this._params?.include || []; | ||
} | ||
@@ -286,4 +286,2 @@ _buildUrlParams() { | ||
_urlBrowseParams() { | ||
if (this._params === void 0) | ||
return {}; | ||
let urlBrowseParams = {}; | ||
@@ -391,3 +389,11 @@ if (this._params.browseParams === void 0) | ||
} catch (e) { | ||
console.log("error", e); | ||
return { | ||
status: "error", | ||
errors: [ | ||
{ | ||
type: "FetchError", | ||
message: e.toString() | ||
} | ||
] | ||
}; | ||
} | ||
@@ -425,3 +431,3 @@ return result; | ||
getIncludes() { | ||
return this._includeFields; | ||
return this._params?.include || []; | ||
} | ||
@@ -494,3 +500,11 @@ _buildUrlParams() { | ||
} catch (e) { | ||
console.log("error", e); | ||
return { | ||
status: "error", | ||
errors: [ | ||
{ | ||
type: "FetchError", | ||
message: e.toString() | ||
} | ||
] | ||
}; | ||
} | ||
@@ -636,3 +650,11 @@ return result; | ||
} catch (e) { | ||
console.log("error", e); | ||
return { | ||
status: "error", | ||
errors: [ | ||
{ | ||
type: "FetchError", | ||
message: e.toString() | ||
} | ||
] | ||
}; | ||
} | ||
@@ -639,0 +661,0 @@ return result; |
@@ -443,2 +443,5 @@ import { z, ZodRawShape } from 'zod'; | ||
type OrderObjectKeyMask<Obj> = { | ||
[k in keyof Obj]?: "ASC" | "DESC"; | ||
}; | ||
/** | ||
@@ -471,3 +474,3 @@ * QueryBuilder class | ||
*/ | ||
browse<P extends { | ||
browse<Fields extends z.objectKeyMask<OutputShape>, Include extends z.objectKeyMask<IncludeShape>, Order extends OrderObjectKeyMask<Shape>, P extends { | ||
order?: string; | ||
@@ -477,4 +480,7 @@ limit?: number | string; | ||
filter?: string; | ||
}, Fields extends z.objectKeyMask<OutputShape>, Include extends z.objectKeyMask<IncludeShape>>(options?: { | ||
input?: BrowseParams<P, Shape>; | ||
_unstable_order?: Order; | ||
}>(options?: { | ||
input?: BrowseParams<P, Shape> & { | ||
_unstable_order?: z.noUnrecognized<Order, Shape>; | ||
}; | ||
output?: { | ||
@@ -481,0 +487,0 @@ fields?: z.noUnrecognized<Fields, OutputShape>; |
@@ -262,3 +262,3 @@ "use strict"; | ||
getIncludes() { | ||
return this._includeFields; | ||
return this._params?.include || []; | ||
} | ||
@@ -286,4 +286,2 @@ _buildUrlParams() { | ||
_urlBrowseParams() { | ||
if (this._params === void 0) | ||
return {}; | ||
let urlBrowseParams = {}; | ||
@@ -391,3 +389,11 @@ if (this._params.browseParams === void 0) | ||
} catch (e) { | ||
console.log("error", e); | ||
return { | ||
status: "error", | ||
errors: [ | ||
{ | ||
type: "FetchError", | ||
message: e.toString() | ||
} | ||
] | ||
}; | ||
} | ||
@@ -425,3 +431,3 @@ return result; | ||
getIncludes() { | ||
return this._includeFields; | ||
return this._params?.include || []; | ||
} | ||
@@ -494,3 +500,11 @@ _buildUrlParams() { | ||
} catch (e) { | ||
console.log("error", e); | ||
return { | ||
status: "error", | ||
errors: [ | ||
{ | ||
type: "FetchError", | ||
message: e.toString() | ||
} | ||
] | ||
}; | ||
} | ||
@@ -636,3 +650,11 @@ return result; | ||
} catch (e) { | ||
console.log("error", e); | ||
return { | ||
status: "error", | ||
errors: [ | ||
{ | ||
type: "FetchError", | ||
message: e.toString() | ||
} | ||
] | ||
}; | ||
} | ||
@@ -639,0 +661,0 @@ return result; |
@@ -10,5 +10,5 @@ { | ||
"type": "git", | ||
"url": "https://github.com/PhilDL/ts-ghost/apps/ghost-blog-buster" | ||
"url": "https://github.com/PhilDL/ts-ghost/tree/main/packages/ts-ghost-core-api" | ||
}, | ||
"version": "0.0.7", | ||
"version": "0.1.0", | ||
"main": "./dist/index.js", | ||
@@ -29,3 +29,4 @@ "module": "./dist/index.mjs", | ||
"vite-tsconfig-paths": "^4.0.5", | ||
"vitest": "^0.29.1" | ||
"vitest": "^0.29.1", | ||
"vitest-fetch-mock": "^0.2.2" | ||
}, | ||
@@ -46,2 +47,3 @@ "dependencies": { | ||
"test:coverage": "vitest --coverage", | ||
"test-ci": "vitest run --coverage.enabled --coverage.reporter='text-summary'", | ||
"lint": "eslint ./src --fix", | ||
@@ -48,0 +50,0 @@ "typecheck": "tsc --project ./tsconfig.json --noEmit" |
323
README.md
@@ -22,3 +22,3 @@ <br/> | ||
`@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. | ||
`@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. | ||
@@ -31,12 +31,325 @@ ## Install | ||
## Query Builders | ||
## QueryBuilder | ||
### Global instantiation | ||
A QueryBuilder is a class that helps you build a query based on a a combinations of ZodSchema. This QueryBuilder exposes 2 methods `read` to fetch a single record and `browse` to fetch multiple records. `read` and `browse` gives you back the appropriate `Fetcher` instance that will handle the actual request to the API with the correct parameters. | ||
Dependeing on the Fetcher you want to use, the instantiation may be a bit different | ||
`QueryBuilder` will handle type-safety of the query parameters and will return the appropriate type based on the `ZodSchema` you pass to it. eg: if you pass the `fields` parameters that "select" fields you want to be present on the response instead of the whole object, then the output schema of the QueryBuilder will change. And then passed to the fetcher to validate data. | ||
### `BrowseQueryBuilder` | ||
### Instantiation | ||
```ts | ||
import type { ContentAPICredentials } from "../schemas"; | ||
import { QueryBuilder } from "./query-builder"; | ||
import { z } from "zod"; | ||
const api: ContentAPICredentials = { | ||
url: "https://ghost.org", | ||
key: "7d2d15d7338526d43c2fadc47c", | ||
version: "v5.0", | ||
endpoint: "posts", | ||
}; | ||
const simplifiedSchema = z.object({ | ||
title: z.string(), | ||
slug: z.string(), | ||
count: z.number().optional(), | ||
}); | ||
// the "include" schema is used to validate the "include" parameters of the API call | ||
// it is specific to the Ghost API endpoint from resource to resource. | ||
// The format is always { 'name_of_the_field': true } | ||
const simplifiedIncludeSchema = z.object({ | ||
count: z.literal(true).optional(), | ||
}); | ||
const qb = new QueryBuilder( | ||
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema }, | ||
api | ||
); | ||
``` | ||
The output schema is really not necessary, since it will be modified along the way after the params are added to the query. At instantiation it will most likely be the same as the original schema so the API may change and we may remove that key. | ||
### Building Queries | ||
After instantiation you can use the `QueryBuilder` 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. | ||
```typescript | ||
import { QueryBuilder, type ContentAPICredentials } from "@ts-ghost/core-api"; | ||
import { z } from "zod"; | ||
const api: ContentAPICredentials = { url: "https://ghost.org", key: "7d2d15d7338526d43c2fadc47c", version: "v5.0", endpoint: "posts",}; | ||
const simplifiedSchema = z.object({ | ||
title: z.string(), | ||
slug: z.string(), | ||
count: z.number().optional(), | ||
}); | ||
const simplifiedIncludeSchema = z.object({ | ||
count: z.literal(true).optional(), | ||
}); | ||
const qb = new QueryBuilder( | ||
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema }, | ||
api | ||
); | ||
let query = qb.browse({ | ||
input: { | ||
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 | ||
}, | ||
output: { | ||
include: { | ||
count: true, | ||
// ^? Available inputs here come from the `simplifiedIncludeSchema` | ||
}, | ||
}, | ||
} as const); | ||
``` | ||
- `input` will accept browse parameters like `page`, `limit`, `order`, `filter`. And read parameters are `id` or `slug`. | ||
- `output` is the same for both methods and let you specify `fields` to output (to not have the full object) and some Schema specific `include`. For example getting the posts including their Authors. | ||
*Ghost Content API doesn't work well when you mix `fields` and `include` output, so in most case you shouldn't* | ||
## `input` | ||
### `.browse` inputs | ||
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 | ||
```typescript | ||
const qb = new QueryBuilder( | ||
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema }, | ||
api | ||
); | ||
let query = qb.browse({ | ||
input: { | ||
page: 1, | ||
limit: 5, | ||
filter: "title:typescript+slug:-test", | ||
order: "title DESC" | ||
} | ||
} as const); // notice the `as const` necessary to have type hints of the order and filter. | ||
``` | ||
These browse params are then parsed through a `Zod` Schema that will validate all the fields. | ||
#### Type-hint with `as const` | ||
You should use `as const` for your input if you are playing with `filter` and `order` so TypeScript can analyse the content of the string statically and TypeCheck it. | ||
- `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 QueryBuilder will throw an Error with message containing the unknown field. | ||
### `.read` inputs | ||
Read is meant to be used to fetch 1 object only by `id` or `slug`. | ||
```typescript | ||
const qb = new QueryBuilder( | ||
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema }, | ||
api | ||
); | ||
let query = qb.read({ | ||
input: { | ||
id: "edHks74hdKqhs34izzahd45" | ||
} | ||
}); | ||
// or | ||
let query = qb.read({ | ||
input: { | ||
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. | ||
## `output` | ||
Output is the same for both `browse` and `read` methods and gives you 2 keys to play with | ||
### `fields` | ||
The `fields` key lets you change the output of the result to have only your selected fields, it works by giving the 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. | ||
```typescript | ||
const qb = new QueryBuilder( | ||
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema }, | ||
api | ||
); | ||
let result = await qb.read({ | ||
input: { | ||
slug: "typescript-is-cool" | ||
}, | ||
output: { | ||
fields: { | ||
slug: true, | ||
title: true | ||
// ^? available fields come form the `simplifiedSchema` passed in the constructor | ||
} | ||
} | ||
} as const).fetch(); | ||
if (result.status === '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` key lets you include some additionnal data that the Ghost API doesn't give you by default. This `include` key is specific to each endpoint and is defined in the `Schema` of the endpoint. You will have to let TypeScript guide you to know what you can include. | ||
```typescript | ||
const qb = new QueryBuilder( | ||
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema }, | ||
api | ||
); | ||
let result = await qb.read({ | ||
input: { | ||
slug: "phildl" | ||
}, | ||
output: { | ||
include: { | ||
"count": true, | ||
}, | ||
}, | ||
} as const).fetch(); | ||
``` | ||
## Fetchers | ||
If the parsing went okay, the `read` and `browse` methods from the `QueryBuilder` 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 QueryBuilder at all and want to fetch directly. | ||
These Fetchers are instantiated in a similar way as the QueryBuilder with a `config` containing the same schemas. But also a set of params | ||
necessary to build the URL to the Ghost API. | ||
```typescript | ||
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 | ||
); | ||
``` | ||
These fetchers have a `fetch` method that will return a discriminated union of 2 types: | ||
```typescript | ||
const qb = new QueryBuilder( | ||
{ schema: simplifiedSchema, output: simplifiedSchema, include: simplifiedIncludeSchema }, | ||
api | ||
); | ||
const readFetcher = qb.read({input: {slug: "typescript-is-cool"}}); | ||
let result = await readFetcher.fetch(); | ||
if (result.status === '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: | ||
```typescript | ||
// example for the read query (the data is an object) | ||
const result: { | ||
status: "success"; | ||
data: z.infer<typeof simplifiedSchema>; // parsed by the Zod Schema and modified by the fields selected | ||
} | { | ||
status: "error"; | ||
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: | ||
```typescript | ||
// example for the browse query (the data is an array of objects) | ||
const result: { | ||
status: "success"; | ||
data: z.infer<typeof simplifiedSchema>[]; | ||
meta: { | ||
pagination: { | ||
pages: number; | ||
limit: number; | ||
page: number; | ||
total: number; | ||
prev: number | null; | ||
next: number | null; | ||
}; | ||
}; | ||
} | { | ||
status: "error"; | ||
errors: { | ||
message: string; | ||
type: string; | ||
}[]; | ||
} | ||
``` | ||
#### Browse `.paginate()` | ||
```typescript | ||
const result: { | ||
status: "success"; | ||
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 | ||
} | { | ||
status: "error"; | ||
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. | ||
## Roadmap | ||
@@ -43,0 +356,0 @@ |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
175703
2511
379
10