Eric Bower
·
17 Aug 24
endpoints.md
1---
2title: Endpoints
3description: endpoints are tasks for managing HTTP requests
4---
5
6An endpoint is just a specialized thunk designed to manage http requests. It has
7a supervisor, it has a middleware stack, and it hijacks the unique id for our
8thunks and turns it into a router.
9
10```ts
11import { createApi, createStore, mdw } from "starfx";
12import { initialState, schema } from "./schema";
13
14const api = createApi();
15// composition of handy middleware for createApi to function
16api.use(mdw.api({ schema }));
17api.use(api.routes());
18// calls `window.fetch` with `ctx.request` and sets to `ctx.response`
19api.use(mdw.fetch({ baseUrl: "https://jsonplaceholder.typicode.com" }));
20
21// automatically cache Response json in datastore as-is
22export const fetchUsers = api.get("/users", api.cache());
23
24// create a POST HTTP request
25export const updateUser = api.post<{ id: string; name: string }>(
26 "/users/:id",
27 function* (ctx, next) {
28 ctx.request = ctx.req({
29 body: JSON.stringify({ name: ctx.payload.name }),
30 });
31 yield* next();
32 },
33);
34
35const store = createStore(initialState);
36store.run(api.register);
37
38store.dispatch(fetchUsers());
39// now accessible with useCache(fetchUsers)
40
41// lets update a user record
42store.dispatch(updateUser({ id: "1", name: "bobby" }));
43```
44
45# Enforcing fetch response type
46
47When using `createApi` and `mdw.fetch` we can provide the type that we think
48will be returned by the fetch response:
49
50```ts
51interface Success {
52 users: User[];
53}
54
55interface Err {
56 error: string;
57}
58
59const fetchUsers = api.get<never, Success, Err>(
60 "/users",
61 function* (ctx, next) {
62 yield* next();
63
64 if (!ctx.json.ok) {
65 // we have an error type
66 console.log(ctx.json.value.error);
67 return;
68 }
69
70 // we have a success type
71 console.log(ctx.json.value.users);
72 },
73);
74```
75
76When calling `createApi` you can also pass it a generic error type that all
77endpoints inherit:
78
79```ts
80import type { ApiCtx } from "starfx";
81
82type MyApiCtx<P = any, S = any> = ApiCtx<P, S, { error: string }>;
83
84const api = createApi<MyApiCtx>();
85
86// this will inherit the types from `MyApiCtx`
87const fetchUsers = api.get<never, Success>(
88 "/users",
89 function* (ctx, next) {
90 yield* next();
91
92 if (!ctx.json.ok) {
93 // we have an error type
94 console.log(ctx.json.value.error);
95 return;
96 }
97
98 // we have a success type
99 console.log(ctx.json.value.users);
100 },
101);
102```
103
104# Using variables inside the API endpoint
105
106Just like other popular server-side routing libraries, we have a way to provide
107slots in our URI to fill with actual values. This is critical for CRUD
108operations that have ids inside the URI.
109
110```ts
111const fetchUsersByAccount = api.get<{ id: string }>("/accounts/:id/users");
112const fetchServices = api.get<{ accountId: string; appId: string }>(
113 "/accounts/:accountId/apps/:appId/services",
114);
115```
116
117One ergonomic feature we baked into this functionality is: what happens when
118`id` is empty?
119
120```ts
121const fetchUsersByAccount = api.get<{ id: string }>("/accounts/:id/users");
122store.dispatch(fetchUsersByAccount({ id: "" }));
123```
124
125In this case we detect that there is no id and bail early. So you can hit this
126endpoint with empty data and it'll just exit early. Convenient when the view
127just throws around data without checking if it is filled.
128
129# The same API endpoints but different logic
130
131It is very common to have the same endpoint with different business logic
132associated with it.
133
134For example, sometimes I need a simple `fetchUsers` endpoint as well as a
135`fetchUsersPoll` endpoint, essentially the same endpoint, but different
136supervisor tasks.
137
138Since the router is defined by a thunk id that must be unique, we have to
139support a workaround:
140
141```ts
142const fetchUsers = api.get("/users");
143const fetchUsersPoll = api.get(["/users", "poll"], { supervisors: poll() });
144```
145
146The first part of the array is what is used for the router, everything else is
147unused. This lets you create as many different variations of calling that
148endpoint that you need.
149
150# `ctx.request`
151
152This is a `Request` object that will feed directly into a `fetch` request.
153End-users are able to manipulate it however they want regardless of what was set
154on it previously. We have mdw that will automatically manipulate it but it all
155lives inside the mdw stack that the end-user can control.
156
157# Using `ctx.req`
158
159`ctx.req` is a helper function to merge what currently exists inside
160`ctx.request` with new properties. It is gaurenteed to return a valid `Request`
161object and performs a deep merge between `ctx.request` and what the user
162provides to it.
163
164```ts
165const fetchUsers = api.get("/users", function*(ctx, next) {
166 ctx.request = ctx.req({
167 url: "/psych",
168 headers: {
169 "Content-Type": "yoyo",
170 },
171 });
172 yield* next();
173}
174```
175
176# `ctx.response`
177
178This is a fetch `Response` object that our `mdw.fetch` will fill automatically.
179
180# `ctx.json`
181
182Our `mdw.fetch` will automatically fill this value as a `Result` type derived
183from `Response.json`. Success or failure of this property is determined by
184`Response.ok` and if we can successully call `Response.json` without errors.
185
186# Middleware automation
187
188Because endpoints use the same powerful middleware system employed by thunks, we
189can do quite a lot of automating for API requests -- to the point where an
190endpoint doesn't have a custom middleware function at all.
191
192For example, if you API leverages an API specification like
193[JSON API](https://jsonapi.org), then we can automate response processing.
194
195Given the following API response:
196
197```json
198{
199 "links": {
200 "self": "http://example.com/articles",
201 "next": "http://example.com/articles?page[offset]=2",
202 "last": "http://example.com/articles?page[offset]=10"
203 },
204 "data": [{
205 "type": "articles",
206 "id": "1",
207 "attributes": {
208 "title": "JSON:API paints my bikeshed!"
209 },
210 "relationships": {
211 "author": {
212 "links": {
213 "self": "http://example.com/articles/1/relationships/author",
214 "related": "http://example.com/articles/1/author"
215 },
216 "data": { "type": "people", "id": "9" }
217 },
218 "comments": {
219 "links": {
220 "self": "http://example.com/articles/1/relationships/comments",
221 "related": "http://example.com/articles/1/comments"
222 },
223 "data": [
224 { "type": "comments", "id": "5" },
225 { "type": "comments", "id": "12" }
226 ]
227 }
228 },
229 "links": {
230 "self": "http://example.com/articles/1"
231 }
232 }],
233 "included": [{
234 "type": "people",
235 "id": "9",
236 "attributes": {
237 "firstName": "Dan",
238 "lastName": "Gebhardt",
239 "twitter": "dgeb"
240 },
241 "links": {
242 "self": "http://example.com/people/9"
243 }
244 }, {
245 "type": "comments",
246 "id": "5",
247 "attributes": {
248 "body": "First!"
249 },
250 "relationships": {
251 "author": {
252 "data": { "type": "people", "id": "2" }
253 }
254 },
255 "links": {
256 "self": "http://example.com/comments/5"
257 }
258 }, {
259 "type": "comments",
260 "id": "12",
261 "attributes": {
262 "body": "I like XML better"
263 },
264 "relationships": {
265 "author": {
266 "data": { "type": "people", "id": "9" }
267 }
268 },
269 "links": {
270 "self": "http://example.com/comments/12"
271 }
272 }]
273}
274```
275
276We could create a middleware:
277
278```ts
279import { createApi, mdw } from "starfx";
280import {
281 createSchema,
282 select,
283 slice,
284 storeMdw,
285 StoreUpdater,
286} from "starfx/store";
287
288interface Article {
289 id: string;
290 title: string;
291 authorId: string;
292 comments: string[];
293}
294
295function deserializeArticle(art: any): Article {
296 return {
297 id: art.id,
298 title: art.attributes.title,
299 authorId: art.relationships.author.data.id,
300 comments: art.relationships.comments.data.map((c) => c.id),
301 };
302}
303
304interface Person {
305 id: string;
306 firstName: string;
307 lastName: string;
308 twitter: string;
309}
310
311function deserializePerson(per: any): Person {
312 return {
313 id: per.id,
314 firstName: per.attributes.firstName,
315 lastName: per.attributes.lastName,
316 twitter: per.attributes.twitter,
317 };
318}
319
320interface Comment {
321 id: string;
322 body: string;
323 authorId: string;
324}
325
326function deserializeComment(com: any): Comment {
327 return {
328 id: comm.id,
329 body: com.attributes.body,
330 authorId: com.relationships.author.data.id,
331 };
332}
333
334const [schema, initialState] = createSchema({
335 cache: slice.table(),
336 loaders: slice.loaders(),
337 token: slice.str(),
338 articles: slice.table<Article>(),
339 people: slice.table<Person>(),
340 comments: slice.table<Comment>(),
341});
342type WebState = typeof initialState;
343
344const api = createApi();
345api.use(mdw.api({ schema }));
346api.use(api.routes());
347
348// do some request setup before making fetch call
349api.use(function* (ctx, next) {
350 const token = yield* select(schema.token.select);
351 ctx.request = ctx.req({
352 headers: {
353 "Content-Type": "application/vnd.api+json",
354 "Authorization": `Bearer ${token}`,
355 },
356 });
357
358 yield* next();
359});
360
361api.use(mdw.fetch({ baseUrl: "https://json-api.com" }));
362
363function process(entity: any): StoreUpdater[] {
364 if (entity.type === "article") {
365 const article = deserializeArticle(entity);
366 return [schema.articles.add({ [article.id]: article })];
367 } else if (entity.type === "people") {
368 const person = deserializePerson(entity);
369 return [schema.people.add({ [person.id]: person })];
370 } else if (entity.type === "comment") {
371 const comment = deserializeComment(entity);
372 return [schema.comments.add({ [comment.id]: comment })];
373 }
374
375 return [];
376}
377
378// parse response
379api.use(function* (ctx, next) {
380 // wait for fetch response
381 yield* next();
382
383 if (!ctx.json.ok) {
384 // bail
385 return;
386 }
387
388 const updaters: StoreUpdater<WebState>[] = [];
389 const jso = ctx.json.value;
390
391 if (Array.isArray(jso.data)) {
392 jso.data.forEach(
393 (entity) => updaters.push(...process(entity)),
394 );
395 } else {
396 updaters.push(...process(jso.data));
397 }
398
399 jso.included.forEach(
400 (entity) => updaters.push(...process(entity)),
401 );
402
403 yield* schema.update(updaters);
404});
405```
406
407Now when we create the endpoints, we really don't need a mdw function for them
408because everything is automated higher in the mdw stack:
409
410```ts
411const fetchArticles = api.get("/articles");
412const fetchArticle = api.get<{ id: string }>("/articles/:id");
413const fetchCommentsByArticleId = api.get<{ id: string }>(
414 "/articles/:id/comments",
415);
416const fetchComment = api.get<{ id: string }>("/comments/:id");
417```
418
419This is simple it is silly not to nomalize the data because we get a ton of
420benefits from treating our front-end store like a database. CRUD operations
421become trivial and app-wide.