feat(api): upgrade zod to v4 and implement api docs and client generation
This commit is contained in:
parent
98776aacb2
commit
87dc6162f4
26 changed files with 4827 additions and 419 deletions
38
src/lib/apiHelpers.ts
Normal file
38
src/lib/apiHelpers.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
import { NextAuthRequest } from 'next-auth';
|
||||
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
|
||||
import zod from 'zod/v4';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
extendZodWithOpenApi(zod);
|
||||
|
||||
export function userAuthenticated(req: NextAuthRequest) {
|
||||
if (!req.auth || !req.auth.user || !req.auth.user.id)
|
||||
return {
|
||||
continue: false,
|
||||
response: { success: false, message: 'Not authenticated' },
|
||||
metadata: { status: 401 },
|
||||
} as const;
|
||||
return { continue: true, user: req.auth.user } as const;
|
||||
}
|
||||
|
||||
export function returnZodTypeCheckedResponse<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Schema extends zod.ZodType<any, any, any>,
|
||||
>(
|
||||
expectedType: Schema,
|
||||
response: zod.input<Schema>,
|
||||
metadata?: { status: number },
|
||||
): NextResponse {
|
||||
const result = expectedType.safeParse(response);
|
||||
if (!result.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid response format',
|
||||
errors: result.error.issues,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(result.data, { status: metadata?.status || 200 });
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
import { loginSchema } from '@/lib/validation/user';
|
||||
import { z } from 'zod/v4';
|
||||
import { loginSchema } from './validation';
|
||||
import { signIn } from '@/auth';
|
||||
|
||||
export async function loginAction(data: z.infer<typeof loginSchema>) {
|
||||
|
|
|
@ -1,46 +1,24 @@
|
|||
'use server';
|
||||
|
||||
import type { z } from 'zod';
|
||||
import type { z } from 'zod/v4';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { registerSchema } from '@/lib/validation/user';
|
||||
import { registerServerSchema } from './validation';
|
||||
import { prisma } from '@/prisma';
|
||||
|
||||
export async function registerAction(data: z.infer<typeof registerSchema>) {
|
||||
export async function registerAction(
|
||||
data: z.infer<typeof registerServerSchema>,
|
||||
) {
|
||||
try {
|
||||
const result = await registerSchema.safeParseAsync(data);
|
||||
const result = await registerServerSchema.safeParseAsync(data);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
error: result.error.errors[0].message,
|
||||
error: result.error.issues[0].message,
|
||||
};
|
||||
}
|
||||
|
||||
const { email, password, firstName, lastName, username } = result.data;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
|
||||
if (user) {
|
||||
return {
|
||||
error: 'User already exist with this email',
|
||||
};
|
||||
}
|
||||
|
||||
const existingUsername = await prisma.user.findUnique({
|
||||
where: {
|
||||
name: username,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUsername) {
|
||||
return {
|
||||
error: 'Username already exists',
|
||||
};
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
|
|
53
src/lib/auth/validation.ts
Normal file
53
src/lib/auth/validation.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import zod from 'zod/v4';
|
||||
import {
|
||||
emailSchema,
|
||||
firstNameSchema,
|
||||
lastNameSchema,
|
||||
newUserEmailServerSchema,
|
||||
newUserNameServerSchema,
|
||||
passwordSchema,
|
||||
userNameSchema,
|
||||
} from '@/app/api/user/validation';
|
||||
|
||||
// ----------------------------------------
|
||||
//
|
||||
// Login Validation
|
||||
//
|
||||
// ----------------------------------------
|
||||
export const loginSchema = zod.object({
|
||||
email: emailSchema.or(userNameSchema),
|
||||
password: zod.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
// ----------------------------------------
|
||||
//
|
||||
// Register Validation
|
||||
//
|
||||
// ----------------------------------------
|
||||
export const registerServerSchema = zod
|
||||
.object({
|
||||
firstName: firstNameSchema,
|
||||
lastName: lastNameSchema,
|
||||
email: newUserEmailServerSchema,
|
||||
password: passwordSchema,
|
||||
confirmPassword: passwordSchema,
|
||||
username: newUserNameServerSchema,
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
export const registerSchema = zod
|
||||
.object({
|
||||
firstName: firstNameSchema,
|
||||
lastName: lastNameSchema,
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
confirmPassword: passwordSchema,
|
||||
username: userNameSchema,
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
});
|
60
src/lib/defaultApiResponses.ts
Normal file
60
src/lib/defaultApiResponses.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import {
|
||||
ErrorResponseSchema,
|
||||
ZodErrorResponseSchema,
|
||||
} from '@/app/api/validation';
|
||||
|
||||
export const invalidRequestDataResponse = {
|
||||
400: {
|
||||
description: 'Invalid request data',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ZodErrorResponseSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const notAuthenticatedResponse = {
|
||||
401: {
|
||||
description: 'Not authenticated',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorResponseSchema,
|
||||
example: {
|
||||
success: false,
|
||||
message: 'Not authenticated',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const userNotFoundResponse = {
|
||||
404: {
|
||||
description: 'User not found',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ErrorResponseSchema,
|
||||
example: {
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const serverReturnedDataValidationErrorResponse = {
|
||||
500: {
|
||||
description: 'Server returned data validation error',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: ZodErrorResponseSchema,
|
||||
example: {
|
||||
success: false,
|
||||
message: 'Server returned data validation error',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
|
@ -1,13 +1,14 @@
|
|||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
export default function useZodForm<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
Schema extends z.ZodType<any, any, any>,
|
||||
Values extends z.infer<Schema>,
|
||||
>(schema: Schema, defaultValues?: Values) {
|
||||
return useForm<Values>({
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return useForm<z.input<typeof schema>, any, z.output<typeof schema>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues,
|
||||
});
|
||||
|
|
36
src/lib/swagger.ts
Normal file
36
src/lib/swagger.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import {
|
||||
OpenAPIRegistry,
|
||||
OpenApiGeneratorV3,
|
||||
} from '@asteasolutions/zod-to-openapi';
|
||||
|
||||
export const registry = new OpenAPIRegistry();
|
||||
|
||||
export const getApiDocs = async () => {
|
||||
const swaggerFiles = require.context('../app', true, /swagger\.ts$/);
|
||||
|
||||
swaggerFiles
|
||||
.keys()
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.forEach((file) => {
|
||||
console.log(`Registering Swagger file: ${file}`);
|
||||
swaggerFiles(file).default?.(registry);
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('@/app/api/validation');
|
||||
|
||||
try {
|
||||
const generator = new OpenApiGeneratorV3(registry.definitions);
|
||||
const spec = generator.generateDocument({
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
version: '1.0.0',
|
||||
title: 'MeetUP',
|
||||
description: 'API documentation for MeetUP application',
|
||||
},
|
||||
});
|
||||
return spec;
|
||||
} catch (error) {
|
||||
console.error('Error generating API docs:', error);
|
||||
throw new Error('Failed to generate API documentation');
|
||||
}
|
||||
};
|
|
@ -1,67 +0,0 @@
|
|||
import zod from 'zod';
|
||||
|
||||
export const loginSchema = zod.object({
|
||||
email: zod
|
||||
.string()
|
||||
.email('Invalid email address')
|
||||
.min(3, 'Email is required')
|
||||
.or(
|
||||
zod
|
||||
.string()
|
||||
.min(3, 'Username is required')
|
||||
.max(32, 'Username must be at most 32 characters long')
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_]+$/,
|
||||
'Username can only contain letters, numbers, and underscores',
|
||||
),
|
||||
),
|
||||
password: zod.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
export const registerSchema = zod
|
||||
.object({
|
||||
firstName: zod
|
||||
.string()
|
||||
.min(1, 'First name is required')
|
||||
.max(32, 'First name must be at most 32 characters long'),
|
||||
lastName: zod
|
||||
.string()
|
||||
.min(1, 'Last name is required')
|
||||
.max(32, 'Last name must be at most 32 characters long'),
|
||||
email: zod
|
||||
.string()
|
||||
.email('Invalid email address')
|
||||
.min(3, 'Email is required'),
|
||||
password: zod
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters long')
|
||||
.max(128, 'Password must be at most 128 characters long'),
|
||||
confirmPassword: zod
|
||||
.string()
|
||||
.min(8, 'Password must be at least 8 characters long')
|
||||
.max(128, 'Password must be at most 128 characters long'),
|
||||
username: zod
|
||||
.string()
|
||||
.min(3, 'Username is required')
|
||||
.max(32, 'Username must be at most 32 characters long')
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_]+$/,
|
||||
'Username can only contain letters, numbers, and underscores',
|
||||
),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
!data.password.includes(data.firstName) &&
|
||||
!data.password.includes(data.lastName) &&
|
||||
!data.password.includes(data.email) &&
|
||||
!data.password.includes(data.username),
|
||||
{
|
||||
message:
|
||||
'Password cannot contain your first name, last name, email, or username',
|
||||
path: ['password'],
|
||||
},
|
||||
);
|
Loading…
Add table
Add a link
Reference in a new issue