Skip to content

Instantly share code, notes, and snippets.

@danecando
Created October 15, 2023 22:46
Show Gist options
  • Save danecando/de17d1dd5e0b1cde60d9ffef0b2b996a to your computer and use it in GitHub Desktop.
Save danecando/de17d1dd5e0b1cde60d9ffef0b2b996a to your computer and use it in GitHub Desktop.
Type safety using json schemas from server to client application
import type { RecordResponse, ListResponse, PaginatedListResponse } from "@pkg/core/api";
import { UserMapper, type UserEntity } from "@pkg/core/user";
export type EntityIdResponse = RecordResponse<{ id: string }>;
/**
* start:users endpoints
*/
export type UserEntityResponse = RecordResponse<UserEntity>;
export type UserEntityListResponse = ListResponse<UserEntity>;
export type UserEntityPaginatedResponse = PaginatedListResponse<UserEntity>;
export const createUser = {
method: "POST",
pathParams: [],
queryParams: [],
bodyParams: ["email", "displayName", "firebaseId"],
path: () => "/users",
mapper: UserMapper.fromApi,
} as const;
export const getAuthenticatedUser = {
method: "GET",
pathParams: [],
queryParams: [],
bodyParams: [],
path: () => "/user",
mapper: UserMapper.fromApi,
} as const;
export const listUsers = {
method: "GET",
pathParams: [],
queryParams: [],
bodyParams: [],
path: () => "/users",
mapper: UserMapper.fromApi,
} as const;
export const updateUser = {
method: "PATCH",
pathParams: [],
queryParams: [],
bodyParams: [
"anniversary",
"displayName",
"email",
"avatarUrl",
"bio",
"location",
"lastLoggedIn",
"active",
"banned",
],
path: () => "/user",
mapper: UserMapper.fromApi,
} as const;
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { UnknownRecord } from "type-fest";
import type {
Method,
QueryParams,
ApiResponse,
RecordResponse,
ListResponse,
PaginatedListResponse,
ExtractRecordType,
FromApiMapper,
} from "@pkg/core/api";
import type { CreateUserBody, UpdateUserBody } from "@pkg/core/user";
import { pick } from "./utils";
import {
type UserEntityListResponse,
type UserEntityResponse,
listUsers,
createUser,
getAuthenticatedUser,
updateUser,
} from "./endpoints";
interface APIClientOptions {
baseUrl: string;
token?: string;
}
export interface RequestParameters<TRecordType extends UnknownRecord> {
path: string;
method: Method;
query?: QueryParams;
body?: Record<string, unknown>;
token?: string;
mapper?: FromApiMapper<any, TRecordType>;
}
/**
* A client for making requests to the REST API server
*
* TODO:
* - Add error handling
*/
export class APIClient {
private baseUrl: string;
private token?: string;
private fetcher: typeof fetch;
constructor(options: APIClientOptions) {
this.baseUrl = options.baseUrl;
this.token = options.token;
this.fetcher = (input, init = {}) =>
fetch(input, {
...init,
headers: {
...init.headers,
"Content-Type": "application/json",
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
});
}
private static mapJsonResponse<
TResponseType = ApiResponse<any>,
TRecordType extends UnknownRecord = ExtractRecordType<TResponseType>,
>(json: any, mapper: FromApiMapper<any, TRecordType>) {
if ("items" in json && Array.isArray(json.items)) {
if ("count" in json) {
return {
...json,
items: json.items.map(mapper),
} satisfies PaginatedListResponse<TRecordType>;
}
return {
...json,
items: json.items.map(mapper),
} satisfies ListResponse<TRecordType>;
}
// If not a list response type, it should be a record of TRecordType
return mapper(json) as unknown as RecordResponse<TRecordType>;
}
public bindToken(token: string) {
return new APIClient({ baseUrl: this.baseUrl, token });
}
public async request<
TResponseType = ApiResponse<any>,
TRecordType extends UnknownRecord = ExtractRecordType<TResponseType>,
>({
path,
method,
query,
body,
token,
mapper = (json) => json,
}: RequestParameters<TRecordType>): Promise<TResponseType> {
const headers: HeadersInit = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
// If the body is empty, don't send the body in the HTTP request
const bodyAsJsonString = !body || Object.entries(body).length === 0 ? undefined : JSON.stringify(body);
const url = new URL(`${this.baseUrl}${path}`);
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
if (Array.isArray(value)) {
value.forEach((val) => url.searchParams.append(key, decodeURIComponent(val)));
} else {
url.searchParams.append(key, String(value));
}
}
}
}
const response = await this.fetcher(url.toString(), {
method: method.toUpperCase(),
headers,
body: bodyAsJsonString,
});
// Can get fancier with types later but this is succificent for now
const json = await response.json();
if (!response.ok) {
throw new Error(json?.message ?? json?.error ?? "Internal server error");
}
return APIClient.mapJsonResponse(json, mapper);
}
public readonly users = {
getAuthenticated: () => {
return this.request<UserEntityResponse>({
path: getAuthenticatedUser.path(),
method: getAuthenticatedUser.method,
mapper: getAuthenticatedUser.mapper,
});
},
create: (args: CreateUserBody) =>
this.request<UserEntityResponse>({
path: createUser.path(),
method: createUser.method,
query: pick(args, createUser.queryParams),
body: pick(args, createUser.bodyParams),
mapper: createUser.mapper,
}),
list: () => {
return this.request<UserEntityListResponse>({
path: listUsers.path(),
method: listUsers.method,
mapper: listUsers.mapper,
});
},
update: (args: UpdateUserBody) => {
return this.request<UserEntityResponse>({
path: updateUser.path(),
method: updateUser.method,
query: pick(args, updateUser.queryParams),
body: pick(args, updateUser.bodyParams),
mapper: updateUser.mapper,
});
},
};
}
import type { UnknownRecord, JsonObject } from "type-fest";
export type FromApiMapper<TJsonResponse extends JsonObject, TRecordType extends UnknownRecord> = (
json: TJsonResponse
) => TRecordType;
export type Method = "GET" | "POST" | "PATCH" | "DELETE" | "PUT";
export type QueryParams = Record<string, string | number | string[]> | URLSearchParams;
export type ApiResponse<TData extends UnknownRecord = UnknownRecord> =
| RecordResponse<TData>
| RecordListResponse<TData>
| PaginatedListResponse<TData>;
export type ApiError = {
statusCode: number;
error: string;
message: string;
};
export type ExtractRecordType<TResponseType> = TResponseType extends ApiResponse<infer U> ? U : never;
export type RecordResponse<TData extends UnknownRecord> = TData;
export type ListResponse<TData extends UnknownRecord> =
| RecordListResponse<TData>
| PaginatedListResponse<TData>;
export type RecordListResponse<TData extends UnknownRecord> = {
items: TData[];
};
export type PaginatedListResponse<TData extends UnknownRecord> = RecordListResponse<TData> & {
count: number;
limit: number;
offset: number;
};
import type { NullToUndefined } from "../utils";
import type { UserRecord } from "./json-schemas";
export type UserEntity = NullToUndefined<UserRecord>;
import type { FromSchema } from "json-schema-to-ts";
import type { DeserializeSchema } from "../json-schema";
export type UserRecord = FromSchema<typeof userRecordSchema, DeserializeSchema>;
export type UserJson = FromSchema<typeof userRecordSchema>;
export type CreateUserBody = FromSchema<typeof createUserBodySchema, DeserializeSchema>;
export type UpdateUserBody = FromSchema<typeof updateUserBodySchema, DeserializeSchema>;
export const userRecordSchema = {
type: "object",
additionalProperties: false,
properties: {
id: { type: "string" },
firebaseId: { type: "string" },
email: { type: "string" },
displayName: { type: "string" },
discriminator: { type: "number" },
lastLoggedIn: { type: "string", format: "date-time" },
bio: { type: ["string", "null"] },
avatarUrl: { type: ["string", "null"] },
anniversary: { type: ["string", "null"] },
location: { type: ["string", "null"] },
active: { type: "boolean" },
banned: { type: "boolean" },
createdAt: { type: "string", format: "date-time" },
updatedAt: { type: "string", format: "date-time" },
},
required: [
"id",
"firebaseId",
"email",
"displayName",
"discriminator",
"lastLoggedIn",
"bio",
"avatarUrl",
"anniversary",
"location",
"active",
"banned",
"createdAt",
"updatedAt",
],
} as const;
export const userRecordListSchema = {
type: "object",
properties: {
items: userRecordSchema,
},
} as const;
const userBodyProperties = {
firebaseId: { type: "string" },
email: { type: "string" },
displayName: { type: "string" },
avatarUrl: { type: "string" },
bio: { type: "string" },
anniversary: { type: "string" },
location: { type: "string" },
lastLoggedIn: { type: "string", format: "date-time" },
active: { type: "boolean" },
banned: { type: "boolean" },
} as const;
export const createUserBodySchema = {
type: "object",
additionalProperties: false,
properties: userBodyProperties,
required: ["firebaseId", "email", "displayName"],
} as const;
export const updateUserBodySchema = {
type: "object",
additionalProperties: false,
properties: userBodyProperties,
} as const;
import type { FromApiMapper } from "../api";
import type { UserJson } from "./json-schemas";
import type { UserEntity } from "./entity";
import { nullToUndefined } from "../utils";
export class UserMapper {
static fromApi: FromApiMapper<UserJson, UserEntity> = (json): UserEntity => {
return {
...nullToUndefined(json),
createdAt: new Date(json.createdAt),
updatedAt: new Date(json.updatedAt),
lastLoggedIn: new Date(json.lastLoggedIn),
};
};
}
import type { FastifyPluginAsync } from "fastify";
import { eq } from "drizzle-orm";
import { users } from "@pkg/db";
import { type RecordId, recordIdSchema } from "@pkg/core/shared-json-schemas";
import type { RecordResponse, RecordListResponse } from "@pkg/core/api";
import {
type UserRecord,
type CreateUserBody,
userEndpoints,
userRecordSchema,
createUserBodySchema,
userRecordListSchema,
} from "@pkg/core/user";
export const usersRoutes: FastifyPluginAsync = async (fastify) => {
const { psql } = fastify;
// GET /users
fastify.get<{ Reply: RecordListResponse<UserRecord> }>(
"/users",
{
schema: {
description: "Get all users",
tags: ["users"],
response: {
200: userRecordListSchema,
},
},
},
async (_, reply) => {
const result = await psql.query.users.findMany();
reply.status(200).send({
items: result,
});
}
);
// POST /users
fastify.route<{ Body: CreateUserBody; Reply: RecordResponse<UserRecord> }>({
method: "POST",
url: "/users",
schema: {
description: "Create new user",
tags: ["users"],
body: createUserBodySchema,
response: {
201: userRecordSchema,
},
},
handler: async (request, reply) => {
const result = await psql.insert(users).values(request.body).returning();
reply.status(201).send(result[0]);
},
});
// GET /users/:id
fastify.get<{ Params: RecordId; Reply: RecordResponse<UserRecord> }>(
"/users/:id",
{
schema: {
description: "Get user by id",
tags: ["users"],
params: recordIdSchema,
response: {
200: userRecordSchema,
},
},
},
async (request, reply) => {
const result = await psql.query.users.findFirst({
where: eq(users.id, request.params.id),
});
reply.status(200).send(result);
}
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment