repos / starfx

supercharged async flow control library.
git clone https://github.com/neurosnap/starfx.git

starfx / docs / posts
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.