Maxvien / next-full-stack

🛠 Effective Full Stack Development with Next.js

Home Page:https://next-full-stack.vercel.app

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

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