Endpoints

endpoints are tasks for managing HTTP requests


An endpoint is just a specialized thunk designed to manage http requests. It has a supervisor, it has a middleware stack, and it hijacks the unique id for our thunks and turns it into a router.

 1import { createApi, createStore, mdw } from "starfx";
 2import { initialState, schema } from "./schema";
 3
 4const api = createApi();
 5// composition of handy middleware for createApi to function
 6api.use(mdw.api({ schema }));
 7api.use(api.routes());
 8// calls `window.fetch` with `ctx.request` and sets to `ctx.response`
 9api.use(mdw.fetch({ baseUrl: "https://jsonplaceholder.typicode.com" }));
10
11// automatically cache Response json in datastore as-is
12export const fetchUsers = api.get("/users", api.cache());
13
14// create a POST HTTP request
15export const updateUser = api.post<{ id: string; name: string }>(
16  "/users/:id",
17  function* (ctx, next) {
18    ctx.request = ctx.req({
19      body: JSON.stringify({ name: ctx.payload.name }),
20    });
21    yield* next();
22  },
23);
24
25const store = createStore(initialState);
26store.run(api.register);
27
28store.dispatch(fetchUsers());
29// now accessible with useCache(fetchUsers)
30
31// lets update a user record
32store.dispatch(updateUser({ id: "1", name: "bobby" }));

Enforcing fetch response type #

When using createApi and mdw.fetch we can provide the type that we think will be returned by the fetch response:

 1interface Success {
 2  users: User[];
 3}
 4
 5interface Err {
 6  error: string;
 7}
 8
 9const fetchUsers = api.get<never, Success, Err>(
10  "/users",
11  function* (ctx, next) {
12    yield* next();
13
14    if (!ctx.json.ok) {
15      // we have an error type
16      console.log(ctx.json.value.error);
17      return;
18    }
19
20    // we have a success type
21    console.log(ctx.json.value.users);
22  },
23);

When calling createApi you can also pass it a generic error type that all endpoints inherit:

 1import type { ApiCtx } from "starfx";
 2
 3type MyApiCtx<P = any, S = any> = ApiCtx<P, S, { error: string }>;
 4
 5const api = createApi<MyApiCtx>();
 6
 7// this will inherit the types from `MyApiCtx`
 8const fetchUsers = api.get<never, Success>(
 9  "/users",
10  function* (ctx, next) {
11    yield* next();
12
13    if (!ctx.json.ok) {
14      // we have an error type
15      console.log(ctx.json.value.error);
16      return;
17    }
18
19    // we have a success type
20    console.log(ctx.json.value.users);
21  },
22);

Using variables inside the API endpoint #

Just like other popular server-side routing libraries, we have a way to provide slots in our URI to fill with actual values. This is critical for CRUD operations that have ids inside the URI.

1const fetchUsersByAccount = api.get<{ id: string }>("/accounts/:id/users");
2const fetchServices = api.get<{ accountId: string; appId: string }>(
3  "/accounts/:accountId/apps/:appId/services",
4);

One ergonomic feature we baked into this functionality is: what happens when id is empty?

1const fetchUsersByAccount = api.get<{ id: string }>("/accounts/:id/users");
2store.dispatch(fetchUsersByAccount({ id: "" }));

In this case we detect that there is no id and bail early. So you can hit this endpoint with empty data and it'll just exit early. Convenient when the view just throws around data without checking if it is filled.

The same API endpoints but different logic #

It is very common to have the same endpoint with different business logic associated with it.

For example, sometimes I need a simple fetchUsers endpoint as well as a fetchUsersPoll endpoint, essentially the same endpoint, but different supervisor tasks.

Since the router is defined by a thunk id that must be unique, we have to support a workaround:

1const fetchUsers = api.get("/users");
2const fetchUsersPoll = api.get(["/users", "poll"], { supervisors: poll() });

The first part of the array is what is used for the router, everything else is unused. This lets you create as many different variations of calling that endpoint that you need.

ctx.request #

This is a Request object that will feed directly into a fetch request. End-users are able to manipulate it however they want regardless of what was set on it previously. We have mdw that will automatically manipulate it but it all lives inside the mdw stack that the end-user can control.

Using ctx.req #

ctx.req is a helper function to merge what currently exists inside ctx.request with new properties. It is gaurenteed to return a valid Request object and performs a deep merge between ctx.request and what the user provides to it.

1const fetchUsers = api.get("/users", function*(ctx, next) {
2  ctx.request = ctx.req({
3    url: "/psych",
4    headers: {
5      "Content-Type": "yoyo",
6    },
7  });
8  yield* next();
9}

ctx.response #

This is a fetch Response object that our mdw.fetch will fill automatically.

ctx.json #

Our mdw.fetch will automatically fill this value as a Result type derived from Response.json. Success or failure of this property is determined by Response.ok and if we can successully call Response.json without errors.

Middleware automation #

Because endpoints use the same powerful middleware system employed by thunks, we can do quite a lot of automating for API requests -- to the point where an endpoint doesn't have a custom middleware function at all.

For example, if you API leverages an API specification like JSON API, then we can automate response processing.

Given the following API response:

 1{
 2  "links": {
 3    "self": "http://example.com/articles",
 4    "next": "http://example.com/articles?page[offset]=2",
 5    "last": "http://example.com/articles?page[offset]=10"
 6  },
 7  "data": [{
 8    "type": "articles",
 9    "id": "1",
10    "attributes": {
11      "title": "JSON:API paints my bikeshed!"
12    },
13    "relationships": {
14      "author": {
15        "links": {
16          "self": "http://example.com/articles/1/relationships/author",
17          "related": "http://example.com/articles/1/author"
18        },
19        "data": { "type": "people", "id": "9" }
20      },
21      "comments": {
22        "links": {
23          "self": "http://example.com/articles/1/relationships/comments",
24          "related": "http://example.com/articles/1/comments"
25        },
26        "data": [
27          { "type": "comments", "id": "5" },
28          { "type": "comments", "id": "12" }
29        ]
30      }
31    },
32    "links": {
33      "self": "http://example.com/articles/1"
34    }
35  }],
36  "included": [{
37    "type": "people",
38    "id": "9",
39    "attributes": {
40      "firstName": "Dan",
41      "lastName": "Gebhardt",
42      "twitter": "dgeb"
43    },
44    "links": {
45      "self": "http://example.com/people/9"
46    }
47  }, {
48    "type": "comments",
49    "id": "5",
50    "attributes": {
51      "body": "First!"
52    },
53    "relationships": {
54      "author": {
55        "data": { "type": "people", "id": "2" }
56      }
57    },
58    "links": {
59      "self": "http://example.com/comments/5"
60    }
61  }, {
62    "type": "comments",
63    "id": "12",
64    "attributes": {
65      "body": "I like XML better"
66    },
67    "relationships": {
68      "author": {
69        "data": { "type": "people", "id": "9" }
70      }
71    },
72    "links": {
73      "self": "http://example.com/comments/12"
74    }
75  }]
76}

We could create a middleware:

  1import { createApi, mdw } from "starfx";
  2import {
  3  createSchema,
  4  select,
  5  slice,
  6  storeMdw,
  7  StoreUpdater,
  8} from "starfx/store";
  9
 10interface Article {
 11  id: string;
 12  title: string;
 13  authorId: string;
 14  comments: string[];
 15}
 16
 17function deserializeArticle(art: any): Article {
 18  return {
 19    id: art.id,
 20    title: art.attributes.title,
 21    authorId: art.relationships.author.data.id,
 22    comments: art.relationships.comments.data.map((c) => c.id),
 23  };
 24}
 25
 26interface Person {
 27  id: string;
 28  firstName: string;
 29  lastName: string;
 30  twitter: string;
 31}
 32
 33function deserializePerson(per: any): Person {
 34  return {
 35    id: per.id,
 36    firstName: per.attributes.firstName,
 37    lastName: per.attributes.lastName,
 38    twitter: per.attributes.twitter,
 39  };
 40}
 41
 42interface Comment {
 43  id: string;
 44  body: string;
 45  authorId: string;
 46}
 47
 48function deserializeComment(com: any): Comment {
 49  return {
 50    id: comm.id,
 51    body: com.attributes.body,
 52    authorId: com.relationships.author.data.id,
 53  };
 54}
 55
 56const [schema, initialState] = createSchema({
 57  cache: slice.table(),
 58  loaders: slice.loaders(),
 59  token: slice.str(),
 60  articles: slice.table<Article>(),
 61  people: slice.table<Person>(),
 62  comments: slice.table<Comment>(),
 63});
 64type WebState = typeof initialState;
 65
 66const api = createApi();
 67api.use(mdw.api({ schema }));
 68api.use(api.routes());
 69
 70// do some request setup before making fetch call
 71api.use(function* (ctx, next) {
 72  const token = yield* select(schema.token.select);
 73  ctx.request = ctx.req({
 74    headers: {
 75      "Content-Type": "application/vnd.api+json",
 76      "Authorization": `Bearer ${token}`,
 77    },
 78  });
 79
 80  yield* next();
 81});
 82
 83api.use(mdw.fetch({ baseUrl: "https://json-api.com" }));
 84
 85function process(entity: any): StoreUpdater[] {
 86  if (entity.type === "article") {
 87    const article = deserializeArticle(entity);
 88    return [schema.articles.add({ [article.id]: article })];
 89  } else if (entity.type === "people") {
 90    const person = deserializePerson(entity);
 91    return [schema.people.add({ [person.id]: person })];
 92  } else if (entity.type === "comment") {
 93    const comment = deserializeComment(entity);
 94    return [schema.comments.add({ [comment.id]: comment })];
 95  }
 96
 97  return [];
 98}
 99
100// parse response
101api.use(function* (ctx, next) {
102  // wait for fetch response
103  yield* next();
104
105  if (!ctx.json.ok) {
106    // bail
107    return;
108  }
109
110  const updaters: StoreUpdater<WebState>[] = [];
111  const jso = ctx.json.value;
112
113  if (Array.isArray(jso.data)) {
114    jso.data.forEach(
115      (entity) => updaters.push(...process(entity)),
116    );
117  } else {
118    updaters.push(...process(jso.data));
119  }
120
121  jso.included.forEach(
122    (entity) => updaters.push(...process(entity)),
123  );
124
125  yield* schema.update(updaters);
126});

Now when we create the endpoints, we really don't need a mdw function for them because everything is automated higher in the mdw stack:

1const fetchArticles = api.get("/articles");
2const fetchArticle = api.get<{ id: string }>("/articles/:id");
3const fetchCommentsByArticleId = api.get<{ id: string }>(
4  "/articles/:id/comments",
5);
6const fetchComment = api.get<{ id: string }>("/comments/:id");

This is simple it is silly not to nomalize the data because we get a ton of benefits from treating our front-end store like a database. CRUD operations become trivial and app-wide.

<< PREV
Thunks
NEXT >>
Dispatch