How to Develop a Typed Safe API with Soki in Next.js
Maxvien opened this issue · comments
I like to work with GraphQL. GraphQL helps me to create a typed-safe API from the server to the client. However, it's not easy to implement GraphQL. There are a lot of steps to deal with GraphQL when you work alone on a full-stack project. So I created soki to solve this problem. Let's dive in to see how it works.
Install Soki
To install soki, I will run this command.
yarn install soki
In the compilerOptions section of the tsconfig.json file, I will change the strict value to true.
// tsconfig.json
{
"compilerOptions": {
...
"strict": true,
...
}
}
Soki Schemas
To work with soki, first, I will create schemas for the API. And I will use these schemas both on the server and the client.
Message Schema
In this section, I will create a function type called hello. And I will put this function type into a child schema called MessageSchema. The hello function will have the input and the output like the code below.
// src/shared/schemas/message.schema.ts
import { createSchema, fn, z } from 'soki';
export const MessageSchema = createSchema({
hello: fn({
input: {
name: z.string(),
},
output: z.string(),
}),
});
Soki is using zod to define schemas. You can find zod's documentation here: https://www.npmjs.com/package/zod
Root Schema
Next, I will combine all child schemas into only one schema called RootSchema. I will use this schema to implement resolver functions on the server. And to create a typed-safe API client to call these functions on the client.
// src/shared/schemas/root.schema.ts
import { createRootSchema } from 'soki';
import { MessageSchema } from './message.schema';
export const RootSchema = createRootSchema({
message: MessageSchema,
});
Soki on Server
Core
Before I implement the RootSchema on the server. I want to create a file named core.ts. This file will contain:
- Context is an interface for the context of resolver functions.
- Resolvers is a type to implement resolvers for the schemas such as MessageSchema, ...
- Re-export createResolver and createRootResolver for easy to use.
// src/backend/core.ts
import type { ResolversType } from 'soki';
import type { RootSchema } from '@shared/schemas/root.schema';
export interface Context {}
export type Resolvers = ResolversType<typeof RootSchema, Context>;
export { createResolver, createRootResolver } from 'soki';
Resolvers
Message Resolver
This step, I will implement MessageSchema with the Resolvers['message'] type.
// src/backend/resolvers/message.resolver.ts
import { Resolvers, createResolver } from '@backend/core';
export const MessageResolver = createResolver<Resolvers['message']>({
hello: async ({ name }, context) => {
return `Hello ${name}!`;
},
});
In the code above, you can see that the hello function has two parameters. The first one is the input parameter. And the second one is the context parameter. We can get the context result from the context function in the RootResolver below.
Root Resolver
Next, I will implement the RootSchema. I will combine all child resolvers into only one resolver called RootResolver as well.
// src/backend/resolvers/root.resolver.ts
import { Context, createRootResolver } from '@backend/core';
import { RootSchema } from '@shared/schemas/root.schema';
import { MessageResolver } from './message.resolver';
export const RootResolver = createRootResolver({
RootSchema,
resolvers: {
message: MessageResolver,
},
context: async (req, res): Promise<Context> => {
return {};
},
});
Don't forget the context function here. It's useful to work on authentication, cookies, database connection, ... for each request.
API Handler
This is the final step to implement the schemas on the server. I will create a handler for the RootResolver. The endpoint of this handler is /api.
// src/pages/api/index.ts
import { createNextHandler } from 'soki/server/next';
import { RootResolver } from '@backend/resolvers/root.resolver';
export const config = { api: { bodyParser: false } };
export default createNextHandler({
RootResolver,
});
I will also disable the bodyParser on this endpoint. So we can upload files via soki.
Soki on Client
API Service
To implement the schemas on the client, I'll create the ApiService like this.
// src/frontend/services/api.service.ts
import { createClient } from 'soki/client';
import { RootSchema } from '@shared/schemas/root.schema';
import { EnvService } from '@shared/services/env.service';
export type { File } from 'soki/client';
export { useQuery, useMuation, getFiles } from 'soki/client';
const clientEndpoint = '/api';
const serverEndpoint = `http${EnvService.isProd() ? 's' : ''}://${EnvService.get('HOST')}/api`;
export const ApiService = createClient({
RootSchema,
endpoint: EnvService.isBrowser() ? clientEndpoint : serverEndpoint,
options: {
onRequest: async () => {
return {
headers: {},
retries: EnvService.isProd() ? 3 : 0,
};
},
},
});
You can also handle fetch's RequestInit options with the onRequest function.
Using the Hello Function
And, to use the hello function from MessageResolver, I will edit the src/pages/index.tsx file with the content below.
// src/pages/index.tsx
import { ApiService, useQuery } from '@frontend/services/api.service';
function getData() {
return ApiService.message.hello({ name: 'Vien' });
}
export async function getServerSideProps() {
const initialData = await getData();
return { props: { initialData } };
}
interface Props {
initialData: string;
}
export default function Page({ initialData }: Props) {
const { loading, data, error, refetch } = useQuery(getData, { deps: [], initialData });
if (loading) return <p>Loading ...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<h1>{data}</h1>
<button onClick={refetch}>Refetch</button>
</div>
);
}
Finally, to see the result of the hello function, please run yarn dev
to see how it works.
Demo: https://next-full-stack-git-issue-4.maxvien.vercel.app/
Source Code
You can find the source code of this tutorial in this branch: https://github.com/Maxvien/next-full-stack/tree/issue-4