diff --git a/packages/io-ts-http/README.md b/packages/io-ts-http/README.md index 7f3d9e1b..ab2c8919 100644 --- a/packages/io-ts-http/README.md +++ b/packages/io-ts-http/README.md @@ -1,178 +1,31 @@ # @api-ts/io-ts-http -Runtime types for (de)serializing HTTP requests from both the client and server side - -## Contents - -- [@api-ts/io-ts-http](#api-tsio-ts-http) - - [Contents](#contents) - - [Preface](#preface) - - [Introduction](#introduction) - - [Overview](#overview) - - [Example](#example) - - [`apiSpec`](#apispec) - - [`httpRoute`](#httproute) - - [`path`](#path) - - [`method`](#method) - - [`request`](#request) - - [`response`](#response) - - [`httpRequest`](#httprequest) - - [`params`](#params) - - [`query`](#query) - - [`headers`](#headers) - - [`body`](#body) - - [Decoding an `httpRequest`](#decoding-an-httprequest) - - [Documentation](#documentation) - -## Preface - -This package extends [io-ts](https://github.com/gcanti/io-ts) with functionality useful -for typing HTTP requests. Start there for base knowledge required to use this package. +Runtime types for serializing and deserializing HTTP requests from both client and +server sides. ## Introduction -io-ts-http is the definition language for api-ts specifications, which define the API -contract for a web sever to an arbitrary degree of precision. Web servers can use the -io-ts-http spec to parse HTTP requests at runtime, and encode HTTP responses. Clients -can use the io-ts-http spec to enforce API compatibility at compile time, and to encode -requests to the server and decode responses. - -## Overview - -The primary function in this library is `httpRequest`. You can use this to build codecs -which can parse a generic HTTP request into a more refined type. The generic HTTP -request should conform to the following interface: - -```typescript -interface GenericHttpRequest { - params: { - [K: string]: string; - }; - query: { - [K: string]: string | string[]; - }; - headers: { - [K: string]: string; - }; - body?: unknown; -} -``` - -Here, `params` represents the path parameters and `query` is minimally-parsed query -string parameters (basically just the results of splitting up the query string and -urlDecoding the values). The `httpRequest` function can be combined with codecs from -`io-ts` to build a combined codec that is able to validate, parse, and encode these -generic HTTP requests into a more refined object. For example: +Use io-ts-http as the definition language for api-ts specifications. It helps you define +precise API contracts for web servers. Web servers use the io-ts-http spec to parse HTTP +requests at runtime and encode HTTP responses. Clients use the io-ts-http spec to +enforce API compatibility at compile time and handle request/response encoding and +decoding. -```typescript -import { httpRequest, optional } from '@api-ts/io-ts-http'; -import { DateFromISOString, NumberFromString } from 'io-ts-types'; +## Installation -const ExampleHttpRequest = httpRequest({ - query: { - id: NumberFromString, - time: optional(DateFromISOString), - }, -}); +```bash +npm install @api-ts/io-ts-http io-ts ``` -This builds a codec that can be given an arbitrary HTTP request and will ensure that it -contains an `id` parameter, and also optionally will check for a `time` parameter, and -if it is present, validate and parse it to a `Date`. If decoding succeeds, then the -resulting value's type will be: - -```typescript -type ExampleDecodedResult = { - id: number; - time?: Date; -}; -``` - -This type is properly inferred by TypeScript and can be used in destructuring like so: - -```typescript -import { pipe } from 'fp-ts/function'; -import * as E from 'fp-ts/Either'; - -const { id, time } = pipe( - ExampleHttpRequest.decode(request), - E.getOrElseW((decodeErrors) => { - someErrorHandler(decodeErrors); - }), -); -``` - -to get request argument validation and parsing as a one-liner. These codecs can also be -used from the client-side to get the type safety around making outgoing requests. An API -client could hypothetically have a method like: - -```typescript -apiClient.request(route, ExampleHttpRequest, { - id: 1337, - time: new Date(), -}); -``` - -If both the server and client use the same codec for the request, then it becomes -possible to encode the API contract (or at least as much of it that is possible to -express in the type system) and therefore someone calling the API can be confident that -the server will correctly interpret a request if the arguments typecheck. - -## Example - -Let's define the api-ts spec for a hypothetical `message-user` service. The conventional -top-level export is an -[`apiSpec`](https://github.com/BitGo/api-ts/blob/master/packages/io-ts-http/docs/apiSpec.md) -value; for example: - -### `apiSpec` - -```typescript -import { apiSpec } from '@api-ts/io-ts-http'; - -import { GetMessage, CreateMessage } from './routes/message'; -import { GetUser, CreateUser, PatchUser, UpdateUser, DeleteUser } from './routes/user'; - -/** - * message-user service - * - * @version 1.0.0 - */ -export const API = apiSpec({ - 'api.v1.message': { - get: GetMessage, - post: CreateMessage, - }, - 'api.v1.user': { - get: GetUser, - post: CreateUser, - put: UpdateUser, - delete: DeleteUser, - patch: PatchUser, - }, -}); -``` - -The `apiSpec` is imported, along with some named `httpRoute`s (`{Get|Create}Message`, -and `{Get|Create|Update|Delete}User`) [which we'll discuss below](#httproute). - -> Currently, if you add the `@version` JSDoc tag to the exported API spec, it will be -> used as the API `version` when generating an OpenAPI schema. Support for other tags -> may be added in the future. - -The top-level export for `message-user-types` is `API`, which we define as an `apiSpec` -with two endpoints `api/v1/message` and `api/v1/user`. The `api/v1/message` endpoint -responds to `GET` and `POST` verbs while the second reponds to `GET`, `POST`, `PUT`, and -`DELETE` verbs using `httpRoute`s defined in `./routes/message`. The following are the -`httpRoute`s defined in `./routes/message`. - -### `httpRoute` +## Quick example ```typescript import * as t from 'io-ts'; -import { httpRoute, httpRequest } from '@api-ts/io-ts-http'; +import { apiSpec, httpRoute, httpRequest } from '@api-ts/io-ts-http'; +import { NumberFromString } from 'io-ts-types'; -export const GetMessage = httpRoute({ +// Create a route for getting a message by ID +const GetMessage = httpRoute({ path: '/message/{id}', method: 'GET', request: httpRequest({ @@ -191,192 +44,31 @@ export const GetMessage = httpRoute({ }, }); -export const CreateMessage = httpRoute({ - path: '/message', - method: 'POST', - request: httpRequest({ - body: { - message: t.string, - }, - }), - response: { - 200: t.type({ - id: t.string, - message: t.string, - }), - 404: t.type({ - error: t.string, - }), - }, -}); -``` - -The first import is the `io-ts` package. It's usually imported `as t` for use in -describing the types of data properties. Again, review -[io-ts](https://github.com/gcanti/io-ts) documentation for more context on how to use it -and this package. - -Then `httpRoute` and `httpRequest` are imported. We'll review the -[`httpRequest`](#httprequest) below, but first, let's review the `GetMessage` -`httpRoute`. - -```typescript -export const GetMessage = httpRoute({ - path: '/message/{id}', - method: 'GET', - request: httpRequest({ - params: { - id: t.string, - }, - }), - response: { - 200: t.type({ - id: t.string, - message: t.string, - }), - 404: t.type({ - error: t.string, - }), +// Define your API specification +export const API = apiSpec({ + 'api.v1.message': { + get: GetMessage, }, }); ``` -[`httpRoute`](https://github.com/BitGo/api-ts/blob/master/packages/io-ts-http/docs/httpRoute.md)s -`apiSpec`s use -[`httpRoute`](https://github.com/BitGo/api-ts/blob/master/packages/io-ts-http/docs/httpRoute.md)s -to define the `path`, `method`, `request` and `response` of a route. - -#### `path` - -The route's `path` along with possible path variables. You should surround variables -with brackets like `{name}`, and are to the `request` codec under the `params` property. - -#### `method` - -The route's `method` is the -[HTTP request method](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) to use -for that route. In our `GetMessage` example, the `method` is `GET`, while in our -`PostMessage` example, the `method` is `POST`. - -#### `request` - -The route's `request` is the output of the `httpRequest` function. This will be -described below. - -#### `response` - -The route's `response` describes the possible -[HTTP responses](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status) the route can -produce. The key-value pairs of the `response` object are an HTTP status code followed -by the `io-ts` type of the response body. In our `GetMessage` example, a `200` status -response yields a payload of a JSON object with two properties, `message` which is a -`string` and `id` which is also a `string`, and a `404` yeilds a payload of a JSON -object with a single property `error` which is a `String`. - -### `httpRequest` - -Use `httpRequest` to build the expected type that you pass in a request to the route. In -our example `GetMessage` - -```typescript -export const GetMessage = httpRoute({ - path: '/message/{id}', - method: 'GET', - request: httpRequest({ - params: { - id: t.string, - }, - }), - // ... -}); -``` - -`httpRequest`s have a total of 4 optional properties: `params` (shown in the example), -`query`, `headers`, and `body`. - -#### `params` - -`params` is an object representing the types of path parameters in a URL. Any URL -parameters in the `path` property of an `httpRoute` must be accounted for in the -`params` property of the `httpRequest`. Our request has a single URL parameter it is -expecting, `id`. This is the type of this parameter is captured in the `params` object -of our `httpRequest`. - -#### `query` - -`query` is the object representing the values passed in via query parameters at the end -of a URL. The following example uses a new route, `GetMessages`, to our API that -searches messages related to a specific `author`: - -```typescript -export const GetMessages = httpRoute({ - path: '/messages', - method: 'GET', - request: httpRequest({ - query: { - author: t.string, - }, - }), - // ... -}); -``` - -#### `headers` - -`headers` is an object representing the types of the -[HTTP headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers) passed in with -a request. - -#### `body` - -`body` is an object representing the type of the -[HTTP body](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages#body) of a -request. Often this is a JSON object. The `CreateMessage` `httpRoute` in our example -uses the `body` property: - -```typescript -export const CreateMessage = httpRoute({ - path: '/message', - method: 'POST', - request: httpRequest({ - body: { - message: t.string, - }, - }), - // ... -}); -``` - -#### Decoding an `httpRequest` - -When you decode `httpRequests` using `io-ts` helpers, the properties of the request are -flattened like this: +## Documentation -```typescript -import { DateFromISOString, NumberFromString } from 'io-ts-types'; +For comprehensive documentation, visit our +[official documentation site](https://bitgo.github.io/api-ts/docs/reference/io-ts-http): -// build an httpRequest with one parameter id and a body with content and a timestamp -const Request = httpRequest({ - params: { - id: NumberFromString, - }, - body: { - content: t.string, - timestamp: DateFromISOString, - }, -}); +- **Tutorials**: Step-by-step guides to get started -// use io-ts to get the type of the Request -type Request = t.TypeOf; + - [Create an API specification](https://bitgo.github.io/api-ts/docs/tutorial-basics/create-an-api-spec) + - [Create an HTTP server](https://bitgo.github.io/api-ts/docs/tutorial-basics/create-an-http-server) + - [Create an HTTP client](https://bitgo.github.io/api-ts/docs/tutorial-basics/create-an-http-client) -// same as -type Request = { - id: number; - content: string; - timestamp: Date; -}; -``` +- **Reference**: Technical documentation + - [apiSpec](https://bitgo.github.io/api-ts/docs/reference/io-ts-http/api-spec) + - [httpRoute](https://bitgo.github.io/api-ts/docs/reference/io-ts-http/http-route) + - [httpRequest](https://bitgo.github.io/api-ts/docs/reference/io-ts-http/http-request) + - [combinators](https://bitgo.github.io/api-ts/docs/reference/io-ts-http/combinators) -## Documentation +## License -- [API Reference](https://github.com/BitGo/api-ts/blob/master/packages/io-ts-http/docs/apiReference.md) +MIT diff --git a/website/docs/reference/index.md b/website/docs/reference/index.md new file mode 100644 index 00000000..a02bccbc --- /dev/null +++ b/website/docs/reference/index.md @@ -0,0 +1,14 @@ +--- +sidebar_position: 1 +--- + +# API Reference + +Find detailed technical documentation for api-ts components in this section. + +## io-ts-http + +- [apiSpec](./io-ts-http/api-spec): Define a collection of routes +- [httpRoute](./io-ts-http/http-route): Create individual HTTP routes +- [httpRequest](./io-ts-http/http-request): Build HTTP request codecs +- [combinators](./io-ts-http/combinators): Create more expressive types diff --git a/website/docs/reference/io-ts-http/api-spec.md b/website/docs/reference/io-ts-http/api-spec.md new file mode 100644 index 00000000..b12c2d24 --- /dev/null +++ b/website/docs/reference/io-ts-http/api-spec.md @@ -0,0 +1,50 @@ +--- +sidebar_position: 1 +--- + +# apiSpec + +Define a collection of routes and associate them with operation names. + +## Usage + +Create a top-level export using `apiSpec` to define your service's API as a collection +of operations linked to routes. This function enforces the correct type for the +parameter you pass to it. + +```typescript +import { apiSpec } from '@api-ts/io-ts-http'; + +import { GetMessage, CreateMessage } from './routes/message'; +import { GetUser, CreateUser, PatchUser, UpdateUser, DeleteUser } from './routes/user'; + +/** + * Example service + * + * @version 1.0.0 + */ +export const API = apiSpec({ + 'api.v1.message': { + get: GetMessage, + post: CreateMessage, + }, + 'api.v1.user': { + get: GetUser, + post: CreateUser, + put: UpdateUser, + delete: DeleteUser, + patch: PatchUser, + }, +}); +``` + +Other packages can read these API specs to: + +- Create type-checked API clients +- Bind functions to routes with automatic parsing and validation +- Generate OpenAPI schemas + +## Metadata + +Add a `@version` JSDoc tag to the exported API spec to set the API version when +generating an OpenAPI schema. We may add support for more tags in the future. diff --git a/website/docs/reference/io-ts-http/combinators.md b/website/docs/reference/io-ts-http/combinators.md new file mode 100644 index 00000000..c5798ad3 --- /dev/null +++ b/website/docs/reference/io-ts-http/combinators.md @@ -0,0 +1,107 @@ +--- +sidebar_position: 4 +--- + +# Combinators + +Use these `io-ts-http` combinators with `io-ts` codecs to create more expressive types. + +## optionalize + +Create object types with both required and optional properties easily. This combinator +accepts the same properties as `type` and `partial` in `io-ts`, combining their +functionality. It automatically identifies properties that can be `undefined` and marks +them as optional. + +### Example + +```typescript +const Item = optionalize({ + necessaryProperty: t.string, + maybeDefined: t.union([t.string, t.undefined]), +}); +``` + +This creates a codec for: + +```typescript +type Item = { + necessaryProperty: string; + maybeDefined?: string; +}; +``` + +You could define this same type using a combination of `intersection`, `type`, and +`partial`, but `optionalize` is more readable, especially when combined with the +`optional` combinator. + +## optional + +Make any codec optional by combining it with `undefined`. This combinator works well +with `optionalize` to clearly show which parameters are optional. + +### Example + +```typescript +// Creates string | undefined +const Foo = optional(t.string); +``` + +## flattened + +Define codecs that flatten properties when decoding and nest them when encoding. + +### Example + +```typescript +const Item = flattened({ + first: { + second: t.string, + }, +}); + +// When decoded, you get: +type DecodedType = { + second: string; +}; + +// When encoded, you get: +type EncodedType = { + first: { + second: string; + }; +}; +``` + +You can flatten multiple top-level properties into one object: + +```typescript +const Item = flattened({ + foo: { + fizz: t.string, + }, + bar: { + buzz: t.number, + }, +}); + +// When decoded, you get: +type DecodedType = { + fizz: string; + buzz: number; +}; + +// When encoded, you get: +type EncodedType = { + foo: { + fizz: string; + }; + bar: { + buzz: number; + }; +}; +``` + +The library tries to prevent you from defining multiple nested properties with the same +key, but it can't catch all cases. If you work around this protection, the behavior is +undefined. diff --git a/website/docs/reference/io-ts-http/http-request.md b/website/docs/reference/io-ts-http/http-request.md new file mode 100644 index 00000000..ec15e5c8 --- /dev/null +++ b/website/docs/reference/io-ts-http/http-request.md @@ -0,0 +1,178 @@ +--- +sidebar_position: 3 +--- + +# httpRequest + +Define HTTP request codecs that create a flattened type from query parameters, path +parameters, headers, and body content. Pass an object with codecs for these components +to create a codec that flattens them when decoded and places them in their appropriate +positions when encoded. All parameters are optional and default to an empty object `{}`. + +## Properties + +You can include these optional properties in the object you pass to `httpRequest`: + +### params + +Define the types of path parameters in a URL. Include any URL parameters that appear in +the `path` property of your `httpRoute`. + +```typescript +export const GetMessage = httpRoute({ + path: '/message/{id}', + method: 'GET', + request: httpRequest({ + params: { + id: t.string, + }, + }), + // ... +}); +``` + +### query + +Define the query parameters that appear at the end of a URL. + +```typescript +export const GetMessages = httpRoute({ + path: '/messages', + method: 'GET', + request: httpRequest({ + query: { + author: t.string, + }, + }), + // ... +}); +``` + +### headers + +Define the [HTTP headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers) +that should be included with the request. + +```typescript +export const GetSecureResource = httpRoute({ + path: '/secure-resource', + method: 'GET', + request: httpRequest({ + headers: { + authorization: t.string, + }, + }), + // ... +}); +``` + +### body + +Define the [HTTP body](https://developer.mozilla.org/en-US/docs/Web/HTTP/Messages#body) +of the request, typically as a JSON object. + +```typescript +export const CreateMessage = httpRoute({ + path: '/message', + method: 'POST', + request: httpRequest({ + body: { + message: t.string, + }, + }), + // ... +}); +``` + +## Examples + +### Request with a single query parameter + +```typescript +const Request = httpRequest({ + query: { + message: t.string, + }, +}); + +// Decoded type +type DecodedRequest = { + message: string; +}; +``` + +### Request with both query and path parameters + +```typescript +const Request = httpRequest({ + query: { + message: t.string, + }, + params: { + id: NumberFromString, + }, +}); + +// Decoded type +type DecodedRequest = { + message: string; + id: number; +}; +``` + +### Request with a body + +```typescript +const Request = httpRequest({ + params: { + id: NumberFromString, + }, + body: { + content: t.string, + timestamp: DateFromISOString, + }, +}); + +// Decoded type +type DecodedRequest = { + id: number; + content: string; + timestamp: Date; +}; +``` + +## How flattening works + +When you decode `httpRequests` using `io-ts` helpers, the system flattens all properties +into a single object: + +```typescript +import { DateFromISOString, NumberFromString } from 'io-ts-types'; + +// Create a request with a path parameter and a body +const Request = httpRequest({ + params: { + id: NumberFromString, + }, + body: { + content: t.string, + timestamp: DateFromISOString, + }, +}); + +// Get the TypeScript type of the Request +type Request = t.TypeOf; + +// The resulting type is: +type Request = { + id: number; + content: string; + timestamp: Date; +}; +``` + +## Limitations + +Currently, `httpRequest` only supports object-type request bodies. For other body types, +see the workaround in the +[httpRoute documentation](./http-route#using-a-non-object-body-type). diff --git a/website/docs/reference/io-ts-http/http-route.md b/website/docs/reference/io-ts-http/http-route.md new file mode 100644 index 00000000..84839e50 --- /dev/null +++ b/website/docs/reference/io-ts-http/http-route.md @@ -0,0 +1,198 @@ +--- +sidebar_position: 2 +--- + +# httpRoute + +Define an individual HTTP route by providing an object with four properties. + +## Properties + +### path + +Specify the route's path with any path variables. Surround variables with brackets like +`{name}`. The request codec passes these variables to the `params` object. + +Example route with no path parameters: + +```typescript +httpRoute({ + path: '/example', + method: 'GET', + request: httpRequest({ + params: { + // Nothing needed here, `params` can be undefined + }, + }), + response: { + 200: t.string, + }, +}); +``` + +Example with parameters: + +```typescript +httpRoute({ + path: '/example/{id}', + method: 'GET', + request: httpRequest({ + params: { + id: NumberFromString, + }, + }), + response: { + 200: t.string, + }, +}); +``` + +### method + +Specify the HTTP method for this route. Use GET, POST, PUT, PATCH, or DELETE. We may add +support for other methods in the future. + +### request + +Define the codec that decodes incoming HTTP requests on the server side and encodes +outgoing requests on the client side. Typically, combine it with the `httpRequest` codec +builder function. + +```typescript +const Route = httpRoute({ + path: '/example/{id}', + method: 'GET', + request: httpRequest({ + params: { + id: NumberFromString, + }, + }), + response: { + 200: t.string, + }, +}); + +// Due to property flattening, the decoded type is: +type RequestProps = { + id: number; +}; + +// Create a client using `superagent-wrapper` +const routeApiClient: (props: RequestProps) => Promise; + +// The client handles type checking and parameter placement automatically +const response: string = await routeApiClient({ id: 1337 }); +``` + +### response + +Define the possible responses that a route may return, along with the codec for each +response. Response keys correspond to HTTP status codes. The system assumes incoming +responses are JSON. + +```typescript +const Route = httpRoute({ + path: '/example', + method: 'GET', + request: httpRequest({}), + response: { + 200: t.type({ + foo: t.string, + }), + 404: t.type({ + message: t.string, + }), + 400: t.type({ + message: t.string, + }), + }, +}); +``` + +## Advanced usage + +Sometimes the `httpRequest` function isn't expressive enough for a particular route. You +can combine it with other `io-ts` functions to work around limitations. The `request` +property passed to `httpRoute` only needs to conform to this output type: + +```typescript +type GenericHttpRequest = { + params: { + [K: string]: string; + }; + query: { + [K: string]: string | string[]; + }; + headers: { + [K: string]: string; + }; + body?: Json; +}; +``` + +Any codec that outputs an object matching that type works. Here are some common +workarounds: + +### Using a non-object body type + +By default, `httpRequest` assumes a request body is an object with properties that can +be flattened. For other types (like a string body), combine `httpRequest` with another +codec: + +```typescript +const StringBodyRoute = httpRoute({ + path: '/example/{id}', + method: 'POST', + request: t.intersection([ + httpRequest({ + params: { id: t.string }, + }), + t.type({ + body: t.string, + }), + ]), + response: { + 200: t.string, + }, +}); + +// Decoded type: +type DecodedType = { + id: string; + body: string; +}; +``` + +This approach adds a `body` property to the decoded type, which breaks the abstraction +but works effectively. + +### Creating conditional query parameters + +For query parameters that depend on other parameter values, use a union of multiple +`httpRequests`: + +```typescript +const UnionRoute = httpRoute({ + path: '/example', + method: 'GET', + request: t.union([ + httpRequest({ + query: { + type: t.literal('ping'), + }, + }), + httpRequest({ + query: { + type: t.literal('message'), + message: t.string, + }, + }), + ]), + response: { + 200: t.string, + }, +}); + +// Decoded type: +type DecodedType = { type: 'ping' } | { type: 'message'; message: string }; +``` diff --git a/website/docs/reference/io-ts-http/index.md b/website/docs/reference/io-ts-http/index.md new file mode 100644 index 00000000..d840d4b5 --- /dev/null +++ b/website/docs/reference/io-ts-http/index.md @@ -0,0 +1,56 @@ +--- +sidebar_position: 1 +--- + +# io-ts-http Reference + +Find detailed technical reference documentation for the `io-ts-http` package in this +section. + +## Core components + +- [apiSpec](./api-spec): Define a collection of routes +- [httpRoute](./http-route): Create individual HTTP routes +- [httpRequest](./http-request): Build HTTP request codecs +- [combinators](./combinators): Create more expressive types + +## Installation + +```bash +npm install @api-ts/io-ts-http io-ts +``` + +## Basic usage + +```typescript +import * as t from 'io-ts'; +import { apiSpec, httpRoute, httpRequest } from '@api-ts/io-ts-http'; + +// Create a route +const GetUser = httpRoute({ + path: '/users/{id}', + method: 'GET', + request: httpRequest({ + params: { + id: t.string, + }, + }), + response: { + 200: t.type({ + id: t.string, + name: t.string, + email: t.string, + }), + 404: t.type({ + error: t.string, + }), + }, +}); + +// Define your API specification +export const API = apiSpec({ + 'api.v1.users': { + get: GetUser, + }, +}); +```