Created
October 15, 2023 22:46
-
-
Save danecando/de17d1dd5e0b1cde60d9ffef0b2b996a to your computer and use it in GitHub Desktop.
Type safety using json schemas from server to client application
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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, | |
}); | |
}, | |
}; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { NullToUndefined } from "../utils"; | |
import type { UserRecord } from "./json-schemas"; | |
export type UserEntity = NullToUndefined<UserRecord>; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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), | |
}; | |
}; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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