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.