Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

zod+openapi type cannot be inferred #796

Open
pincman opened this issue Oct 26, 2024 · 5 comments
Open

zod+openapi type cannot be inferred #796

pincman opened this issue Oct 26, 2024 · 5 comments

Comments

@pincman
Copy link

pincman commented Oct 26, 2024

I'm using hono.js in next.js. When using openapi and zod for data validation, I've found that it's not possible to correctly infer the types of input values or response values through zod. For example:

// src/server/post/schema.ts

import { z } from '@hono/zod-openapi';

export const postSchema = z
    .object({
        id: z.string(),
        title: z.string(),
        thumb: z.string(),
        summary: z.string().nullable().optional(),
        keywords: z.string().nullable().optional(),
        description: z.string().nullable().optional(),
        slug: z.string().nullable().optional(),
        body: z.string(),
        createdAt: z.coerce.date(),
        updatedAt: z.coerce.date(),
    })
    .strict();
export type PostItem = z.infer<typeof postSchema>;

export const postPaginateQuerySchema = z.object({
    page: z.coerce.number().optional(),
    limit: z.coerce.number().optional(),
    orderBy: z.enum(['asc', 'desc']).optional(),
});
export const postPaginateResultSchema = z.object({
    items: z.array(postSchema),
    meta: z.object({
        itemCount: z.coerce.number(),
        totalItems: z.coerce.number(),
        perPage: z.coerce.number(),
        totalPages: z.coerce.number(),
        currentPage: z.coerce.number(),
    }),
});
export type PostPaginate = z.infer<typeof postPaginateResultSchema>;
// ...

```ts
// src/server/post/endpoints.ts

import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi';

import { PostCreateData, PostUpdateData } from '@/app/_components/post/types';

import {
    postCreateSchema,
    postPageNumbersResultSchema,
    postPaginateQuerySchema,
    postPaginateResultSchema,
    postSchema,
    postUpdateSchema,
} from './schema';
import {
    createPostItem,
    deletePostItem,
    queryPostItem,
    queryPostItemById,
    queryPostItemBySlug,
    queryPostPaginate,
    queryPostTotalPages,
    updatePostItem,
} from './services';

const createGetListApi = (api: OpenAPIHono) => {
    return api.openapi(
        createRoute({
            tags: ['文章操作'],
            method: 'get',
            path: '/',
            request: {
                query: postPaginateQuerySchema,
            },
            responses: {
                200: {
                    content: {
                        'application/json': {
                            schema: postPaginateResultSchema,
                        },
                    },
                },
            },
        }),
        async (c) => {
            try {
                const query = c.req.query();
                const options = Object.fromEntries(
                    Object.entries(query).map(([k, v]) => [k, Number(v)]),
                );
                const result = await queryPostPaginate(options);
                return c.json(result) as any;
            } catch (error) {
                return c.json({ error }, 500);
            }
        },
    );
};
// ...
export const createPostApi = (hono: OpenAPIHono) => {
    let api = createGetListApi(hono);
    api = createGetPageNumbersApi(api);
    api = createGetItemApi(api);
    api = createGetItemByIdApi(api);
    api = createGetItemBySlugApi(api);
    api = createStoreApi(api);
    api = createUpdateApi(api);
    createDeleteApi(api);
    return api;
};
// src/server/main.ts
/* eslint-disable unused-imports/no-unused-vars */
import { swaggerUI } from '@hono/swagger-ui';
import { OpenAPIHono } from '@hono/zod-openapi';
import { hc } from 'hono/client';
import { prettyJSON } from 'hono/pretty-json';

import { createPostApi } from './post/endpoints';

// const app = new Hono().basePath('/api');
const app = new OpenAPIHono().basePath('/api');
app.get('/', (c) => c.text('3R Blog API'));
app.use(prettyJSON());
app.notFound((c) => c.json({ message: 'Not Found', ok: false }, 404));
const api = new OpenAPIHono();
const routes = app.route('/posts', createPostApi(api));
app.get('/doc', swaggerUI({ url: '/api/openapi' }));
app.doc('/openapi', {
    openapi: '3.1.0',
    info: {
        version: 'v1',
        title: '3R blog API',
    },
});
type AppType = typeof routes;
const apiClient = hc<AppType>(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000');
export { app, apiClient, type AppType };
// src/app/(pages)/page.tsx
const HomePage: FC<{ searchParams: IPaginateQueryProps }> = async ({ searchParams }) => {
    const { page: currentPage, limit = 8 } = searchParams;
    // 当没有传入当前页或当前页小于1时,设置为第1页
    const page = isNil(currentPage) || Number(currentPage) < 1 ? 1 : Number(currentPage);
    // const { items, meta } = await queryPostPaginate({ page: Number(page), limit });

    const res = await apiClient.api.posts.$get({
        query: { page, limit },
    });
    const { items, meta } = await res.json();

    if (meta.totalPages && meta.totalPages > 0 && page > meta.totalPages) {
        return redirect('/');
    }

    return (
        <div className="tw-page-container">
            <Suspense fallback={<PageSkeleton />}>
                <Tools />
                <div className={$styles.list}>
                    {items.map((item) => (
                        <div
                            className={$styles.item}
                            // 传入css变量的封面图用于鼠标移动到此处后会出现不同颜色的光晕效果
                            style={{ '--bg-img': `url(${item.thumb})` } as any}
                            key={item.id}
                        >
                            <Link className={$styles.thumb} href={`/posts/${item.slug || item.id}`}>
                                <Image
                                    src={item.thumb}
                                    alt={item.title}
                                    fill
                                    priority
                                    sizes="100%"
                                    unoptimized
                                />
                            </Link>
                            <div className={$styles.content}>
                                <div className={clsx($styles.title, 'tw-hover')}>
                                    <Link href={`/posts/${item.slug || item.id}`}>
                                        <h2 className="tw-ellips tw-animate-decoration tw-animate-decoration-lg">
                                            {item.title}
                                        </h2>
                                    </Link>
                                </div>
                                <div className={$styles.summary}>
                                    {isNil(item.summary)
                                        ? item.body.substring(0, 99)
                                        : item.summary}
                                </div>
                                <div className={$styles.footer}>
                                    <div className={$styles.meta}>
                                        <span>
                                            <AiOutlineCalendar />
                                        </span>
                                        <time className="tw-ellips">
                                            {!isNil(item.updatedAt)
                                                ? formatChineseTime(item.updatedAt)
                                                : formatChineseTime(item.createdAt)}
                                        </time>
                                    </div>
                                    <div className={$styles.meta}>
                                        <PostEditButton id={item.id} />
                                        <PostDelete id={item.id} />
                                    </div>
                                </div>
                            </div>
                        </div>
                    ))}
                </div>
                {meta.totalPages! > 1 && <PostListPaginate limit={limit} page={page} />}
            </Suspense>
        </div>
    );
};

export default HomePage;
image image

However, the types inferred by zod itself are fine, such as:

// src/server/post/schema.ts
export type PostItem = z.infer<typeof postSchema>;
const test: PostItem;

test.createdAt;
image

What could be the reason for this?

@yusukebe
Copy link
Member

@pincman

Can you provide minimal code to reproduce it? It's long and verbose, including unnecessary modules to reproduce.

@pincman
Copy link
Author

pincman commented Oct 26, 2024

@pincman

Can you provide minimal code to reproduce it? It's long and verbose, including unnecessary modules to reproduce.

Yes, of course.

export const postSchema = z
    .object({
        id: z.string(),
       // ...
        createdAt: z.coerce.date(),
        updatedAt: z.coerce.date(),
    })
    .strict();
    
   const createGetItemByIdApi = (api: OpenAPIHono) => {
    return api.openapi(
        createRoute({
            // ...
            responses: {
                200: {
                    content: {
                        'application/json': {
                            schema: postSchema,
                        },
                    },
                },
            },
        }),
        async (c) => {
            try {
                const { id } = c.req.param();
                const result = await queryPostItemById(id);
                return c.json(result) as any;
            } catch (error) {
                return c.json({ error }, 500);
            }
        },
    );
};

in next.js

export const formatChineseTime = (date: Date) => { 
    // some code
}

const PostItemPage: FC<{ params: { item: string } }> = async ({ params }) => {
    // const post = await queryPostItem(params.item);
    const result = await apiClient.api.posts[':item'].$get({ param: { item: params.item } });
    return   <time className="tw-ellips">
                                {!isNil(post.updatedAt)
                                    ? formatChineseTime(post.updatedAt)
                                    : formatChineseTime(post.createdAt)}
                            </time>
}

type error "Argument of type 'string' is not assignable to parameter of type 'Date'"

@yusukebe
Copy link
Member

@pincman thank you for giving the code.

Though you might not like it, this is not a bug. This issue is not only @hono/zod-openapi matter, it's correct hono behavior. If it returns the values with c.json(). The value includes the Date object converted to string because it uses JSON.stringify(). So, the value that you get from the client will be string:

const app = new Hono()

const routes = app.get('/', (c) => {
  // d is converted to string with JSON.stringify()
  return c.json({
    d: new Date()
  })
})

const client = hc<typeof routes>('/')
const res = await client.index.$get()
const data = await res.json()
const d = data.d // string

This is not a bug, correct behavior.

@pincman
Copy link
Author

pincman commented Oct 29, 2024

I may not have expressed myself clearly. The value of the date type will be a string, this is certainly the case. However, why is the inferred type also a string rather than a date?

@yusukebe
Copy link
Member

yusukebe commented Nov 4, 2024

I may not have expressed myself clearly. The value of the date type will be a string, this is certainly the case. However, why is the inferred type also a string rather than a date?

Yes. It will be string. The type definition should follow the actual value.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants