|
| 1 | +--- |
| 2 | +layout: post |
| 3 | +title: REST in peace |
| 4 | +author: [gallayl] |
| 5 | +tags: ['Getting Started', 'rest', 'rest-service', 'rest-client-fetch', 'rest-client-got'] |
| 6 | +image: img/006-getting-started-with-rest-cover.jpg |
| 7 | +date: '2021-06-23T12:58:20.257Z' |
| 8 | +draft: false |
| 9 | +excerpt: Designing and implementing APIs can be hard and consuming them can be frustrating, if they doesn't work as expected. REST API as a Typescript interface for the rescue! |
| 10 | +--- |
| 11 | + |
| 12 | +### The problem 🤷♂️ |
| 13 | + |
| 14 | +The old-fashioned approach is to decouple _nearly everything_, but (as a fullstack dev) when speaking about the relation between REST API's, there is a problem: |
| 15 | + |
| 16 | + |
| 17 | + |
| 18 | +So, let's dig deep into the good old S.O.L.I.D. principles, until the end of it, where **D** stands for **dependency inversion**... khm: |
| 19 | + |
| 20 | +> Entities must depend on abstractions, not on concretions. |
| 21 | +
|
| 22 | +So, again, let's get this straight. The happy path should be: |
| 23 | +1. Design an abstraction (interface) for the API |
| 24 | +1. Implement your API |
| 25 | +1. The client should depend on the abstraction, not the actual implementation |
| 26 | + |
| 27 | +In FuryStack, there is 3 type of REST packages which are meant to solve the scenarios. In order: |
| 28 | +1. `@furystack/rest` for the API design |
| 29 | +1. `@furystack/rest-service` for the implementation |
| 30 | +1. `@furystack/rest-client-fetch` and `@furystack/rest-client-got` for consuming the API |
| 31 | + |
| 32 | +### Prerequisites 👈 |
| 33 | + |
| 34 | +To be able to use this ideal path, you have to share the abstraction between the service and the consumer / frontend. |
| 35 | +The preferred way can be some kind of a Monorepo (see the Boilerplate app's approacth with Yarn Workspaces). |
| 36 | +If you can't do that, you can still extract the definition to e.g. an NPM package. |
| 37 | + |
| 38 | +### Step One - Design your API first. 📐 |
| 39 | + |
| 40 | +If you want to achieve the first step, your API should have an abstraction. An interface. And that should be a.... (drumroll 🥁 )... **Typescript Interface** 🎉 |
| 41 | + |
| 42 | +The following example can give you the idea how you should implement the API |
| 43 | + |
| 44 | +```ts |
| 45 | +import { |
| 46 | + DeleteEndpoint, |
| 47 | + GetCollectionEndpoint, |
| 48 | + GetEntityEndpoint, |
| 49 | + PatchEndpoint, |
| 50 | + PostEndpoint, |
| 51 | + RestApi, |
| 52 | +} from '@furystack/rest' |
| 53 | + |
| 54 | +export interface Mock { |
| 55 | + id: string |
| 56 | + value: string |
| 57 | +} |
| 58 | + |
| 59 | +export interface CustomQuery { |
| 60 | + query: { foo: string; bar: number; baz: boolean } |
| 61 | + result: { foo: string; bar: number; baz: boolean } |
| 62 | +} |
| 63 | +export interface CustomUrl { |
| 64 | + url: { id: number } |
| 65 | + result: { id: number } |
| 66 | +} |
| 67 | +export interface CustomHeaders { |
| 68 | + headers: { foo: string; bar: number; baz: boolean } |
| 69 | + result: { foo: string; bar: number; baz: boolean } |
| 70 | +} |
| 71 | +export interface CustomBody { |
| 72 | + body: { foo: string; bar: number; baz: boolean } |
| 73 | + result: { foo: string; bar: number; baz: boolean } |
| 74 | +} |
| 75 | + |
| 76 | +export interface MyApi extends RestApi { |
| 77 | + GET: { |
| 78 | + '/custom-query': CustomQuery |
| 79 | + '/custom-url/:id': CustomUrl |
| 80 | + '/custom-headers': CustomHeaders |
| 81 | + '/mock': GetCollectionEndpoint<MockEntity> |
| 82 | + '/mock/:id': GetEntityEndpoint<MockEntity, 'id'> |
| 83 | + } |
| 84 | + POST: { |
| 85 | + '/custom-body': CustomBody |
| 86 | + '/mock': PostEndpoint<Mock, 'id'> |
| 87 | + } |
| 88 | + PATCH: { |
| 89 | + '/mock/:id': PatchEndpoint<Mock, 'id'> |
| 90 | + } |
| 91 | + DELETE: { |
| 92 | + '/mock/:id': DeleteEndpoint<Mock, 'id'> |
| 93 | + } |
| 94 | +} |
| 95 | +``` |
| 96 | + |
| 97 | +So the interface is build on two levels. The first level defines the HTTP method. |
| 98 | + |
| 99 | +The second level is actually a _key-value pair_. The key will be the URL endpoint, the second one is the type in a predefined format. |
| 100 | +The custom types can contain the following fields: |
| 101 | + - `result`: This will be the type of the response on success |
| 102 | + - `url`: An URL parameter object. This should be also part of the url (e.g. if you define the `{id: string}` as type, the `/:id` parameter) |
| 103 | + - `headers`: The required explicit headers |
| 104 | + - `query`: The query string parameters |
| 105 | + - `body`: The POST Body parameters |
| 106 | + |
| 107 | +There are also some entity-related shortcut types in the example, e.g. `PatchEndpoint<T, TKey>` stands for `{ body: Partial<T>, url: { id: T[TPrimaryKey] }, result: undefined}` |
| 108 | + |
| 109 | +### Step Two - Implement 🛠 |
| 110 | + |
| 111 | +OK, we've got a well-defined REST API with all the payloads, bodys, headers, bells and whistles. Now we can implement it, so let's move to the _service-side_. |
| 112 | + |
| 113 | +So, an example API implementation overview looks like this: |
| 114 | +```ts |
| 115 | +const i = new Injector() |
| 116 | +i.useRestService<MyApi>({ |
| 117 | + port: 1234, // The port that the app will use |
| 118 | + root: '/api/v1', // The root path of your API |
| 119 | + api: { |
| 120 | + GET: { |
| 121 | + "/custom-headers": customHeadersEndpoint |
| 122 | + } |
| 123 | + /*(...and you should implement all endpoints here if you want to compile it. Good luck ;) )*/ |
| 124 | + } |
| 125 | +}) |
| 126 | +``` |
| 127 | + |
| 128 | +So, the basic structure is similar to the interface, you should use the `.useRestService<T>()` injector extension with some basic options like the port or root path. |
| 129 | +The `api` should follow the same structure as the definition, but the `customHeadersEndpoint` should implement the endpoint `CustomHeaders`. So let's take a look at this: |
| 130 | + |
| 131 | +```ts |
| 132 | +const customHeadersEndpoint: RequestAction<CustomHeaders> = async ({ headers }) => { |
| 133 | + console.log(headers) // The type is inferred from CustomHeaders: { foo: string; bar: number; baz: boolean } |
| 134 | + return JsonResult(headers) // This should also match the `result` type |
| 135 | +} |
| 136 | +``` |
| 137 | + |
| 138 | +The magical part is covered by the `RequestAction<CustomHeaders>`. This generic type tells that this |
| 139 | + - Should be an async method |
| 140 | + - Will recieve headers with a specified type |
| 141 | + - Should return the JsonResult with a specified type |
| 142 | + |
| 143 | +The other properties will be also inferred: |
| 144 | + - if there is `{ url: T }` defined on the interface, you will have `getUrlParams: ()=> T` in the parameter object |
| 145 | + - if there is `{ body: T }` defined, you will get `getBody: () => Promise<T>` in the parameter object |
| 146 | + - if there is `{ query: T }` defined, you will get `getQuery: () => T` |
| 147 | + |
| 148 | +### Consume your interface, not your API 🍽 |
| 149 | + |
| 150 | +OK, we have an interface and an implementation. The interface is shared between the service and the client. So let's take a look how the client can consume the API. |
| 151 | +Let's take a simple approacth with the native `fetch` implementation (there's also a `got`-based client implementation that can be used in node-based clients as well) |
| 152 | + |
| 153 | +```ts |
| 154 | +import { createClient } from '@furystack/rest-client-fetch' |
| 155 | + |
| 156 | +const callMyApi = createClient<MyApi>({ |
| 157 | + endpointUrl: `http://localhost:1234/api/v1`, |
| 158 | +}) |
| 159 | + |
| 160 | +const result = await callMyApi({ |
| 161 | + method: 'GET', // This should be the first property in order to work with Intellisense |
| 162 | + action: '/custom-headers', // After selecting the action from the list, other required properties will be inferred and required |
| 163 | + headers: { |
| 164 | + foo: 'asd', |
| 165 | + bar: 42, |
| 166 | + baz: false, |
| 167 | + }, |
| 168 | +}) |
| 169 | +``` |
| 170 | + |
| 171 | +So, step by step: |
| 172 | +1. Select a method |
| 173 | +1. All available actions will be offered by IntelliSense |
| 174 | +1. Once you've selected an action, the full payload will be inferred. You will get type checks in header, body, query, url, etc... parameters. The response type will be also there for you 💚 |
| 175 | + |
| 176 | +### The main gotcha: Let's break all the thingzzz!!!!4!!!💥 |
| 177 | + |
| 178 | +There's an ancient refactoring technique in the _type-safe dreamlands_: If you can change your type definitions and try to recompile, you will see immediately where it will break your code. |
| 179 | +The good news: Now you can do that with your API definition - your service and your frontend will be still type-protected 🙌 |
| 180 | + |
| 181 | + |
0 commit comments