Build end-to-end typesafe REST APIs using TypeScript.
yarn add @lifeomic/one-schema
At a high level, one-schema
provides end-to-end type safety via the following simple workflow.
- In your service repo, use
OneSchemaRouter
to declare your routes. - Deploy new service code.
- In your client repo, "introspect" your deployed schema using
one-schema fetch-remote-schema
. - In your client repo, generate a type-safe client from this^ introspected schema using
one-schema generate-axios-client
.
Note: for documentation on the legacy flow of defining schemas in a YAML file, see the legacy docs.
First, declare + implement your API routes using OneSchemaRouter
. Use zod
to define your request + response schemas.
import Router from '@koa/router';
import { OneSchemaRouter } from '@lifeomic/one-schema';
import { z } from 'zod';
const router = OneSchemaRouter.create({
using: new Router(),
introspection: {
route: '/private/introspection',
serviceVersion: process.env.LIFEOMIC_SERVICE_VERSION,
},
})
.declare({
route: 'POST /items',
name: 'createItem',
request: z.object({ message: z.string() }),
response: z.object({ id: z.string(), message: z.string() }),
})
.declare({
route: 'GET /items/:id',
name: 'getItemById',
request: z.object({ filter: z.string() }),
response: z.object({ id: z.string(), message: z.string() }),
});
// Be sure to expose your router's routes on a Koa app.
import Koa from 'koa';
const app = new Koa().use(router.middleware());
app.listen();
In case the main router requires authorization headers, and you want to query the introspection route without them, you can expose it on a custom router like so:
const router = OneSchemaRouter.create({
using: new Router(),
introspection: {
route: '/introspection',
router: new Router({ prefix: '/private' }),
serviceVersion: process.env.LIFEOMIC_SERVICE_VERSION,
},
})
.declare({
route: 'POST /items',
name: 'createItem',
request: z.object({ message: z.string() }),
response: z.object({ id: z.string(), message: z.string() }),
})
.declare({
route: 'GET /items/:id',
name: 'getItemById',
request: z.object({ filter: z.string() }),
response: z.object({ id: z.string(), message: z.string() }),
});
Once you have routes declared, add implementations for each route. Enjoy perfect type inference and auto-complete for path parameters, query parameters, and the request body.
router
.implement('POST /items', async (ctx) => {
ctx.request.body; // { message: string }
return { id: 'some-id', message: ctx.request.body.message };
})
.implement('GET /items/:id', async (ctx) => {
ctx.request.query; // { filter: string }
ctx.params; // { id: string }
return { id: 'some-id', message:'some-id' };
})
});
To generate a type-safe client for this new API, we need to:
- Introspect the deployed schema using the
one-schema
CLI. Commit this file. - Generate a client using the introspected schema + the
one-schema
CLI.
one-schema fetch-remote-schema \
--from lambda://my-service:deployed/private/introspection \
--output src/schemas/my-service.json
Then, use the generate-axios-client
command to generate a nicely typed Axios-based client from the schema.
one-schema generate-axios-client \
--schema src/schemas/my-service.json \
--output generated-client.ts \
--name MyService
Now, use the generated client:
import axios from 'axios';
import { MyService } from './generated-client';
// Provide any AxiosInstance, customized to your needs.
const client = new MyService(axios.create({ baseURL: 'https://my.api.com/' }));
// The client has named methods for interacting with each API endpoint.
const response = await client.createItem({
message: 'some-message',
});
console.log(response.data);
// {
// id: 'some-id',
// message: 'some-message'
// }
const response = await client.getItemById({
id: 'some-id',
filter: 'some-filter',
});
console.log(response.data);
// {
// id: 'some-id',
// message: 'some-message'
// }
The generated client provides a built-in helper for reading from paginated LifeOmic APIs:
// example endpoint
router.declare({
route: 'GET /items',
name: 'listItems',
request: z.object({
nextPageToken: z.string(),
pageSize: z.string(),
}),
response: z.object({
items: z.array(
z.object({ id: z.string().optional(), message: z.string().optional() }),
),
links: z.object({
self: z.string(),
next: z.string().optional(),
}),
}),
});
// Automatically paginate using the client.
const result = await client.paginate(client.listPaginatedItems, {
pageSize: '10',
});
result.length; // result is the fully-paginated list of items
Use the generate-open-api-spec
command to generate an OpenAPI spec from a simple schema, which may be useful for interfacing with common OpenAPI tooling.
one-schema generate-open-api-spec \
--schema schema.yml \
--output openapi-schema.json \
--apiVersion "1.0.0" \
--apiTitle "Simple API"
The output (in generated-openapi-schema.json
):
{
"openapi": "3.1.0",
"info": {
"version": "Simple API",
"title": "Simple API"
},
"components": {
"schemas": {
"Item": {
"additionalProperties": false,
"properties": {
"id": {
"description": "The item's unique identifier.",
"type": "string"
},
"label": {
"description": "The item label.",
"type": "string"
}
},
"required": ["id", "label"]
}
}
},
"paths": {
"/items": {
"item": {
"operationId": "createItem",
"responses": {
"200": {
"description": "TODO",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Item"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"label": {
"type": "string"
}
},
"required": ["label"]
}
}
}
}
},
"get": {
"operationId": "listItems",
"responses": {
"200": {
"description": "TODO",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Item"
}
}
}
}
}
},
"parameters": [
{
"in": "query",
"name": "filter",
"schema": {
"type": "string"
},
"required": true
}
]
}
}
}
}
For a full list of API commands and details on usage, run one-schema --help
.
See the in-line documentation in the source code + package for details on the exposed APIs.