feat(api): add swagger docs, /api/user/me GET and PUT endpoints

This commit is contained in:
Dominik 2025-06-11 22:43:37 +02:00
parent c2861047d0
commit 57e420f572
Signed by: dominik
GPG key ID: 06A4003FC5049644
10 changed files with 2980 additions and 265 deletions

View file

@ -39,10 +39,12 @@
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"next": "15.3.3", "next": "15.3.3",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
"next-swagger-doc": "^0.4.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.56.4", "react-hook-form": "^7.56.4",
"swagger-ui-react": "^5.24.1",
"tailwind-merge": "^3.2.0", "tailwind-merge": "^3.2.0",
"zod": "^3.25.60" "zod": "^3.25.60"
}, },
@ -52,6 +54,7 @@
"@types/node": "22.15.31", "@types/node": "22.15.31",
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-dom": "19.1.6", "@types/react-dom": "19.1.6",
"@types/swagger-ui-react": "^5",
"dotenv-cli": "8.0.0", "dotenv-cli": "8.0.0",
"eslint": "9.29.0", "eslint": "9.29.0",
"eslint-config-next": "15.3.3", "eslint-config-next": "15.3.3",

11
src/app/api-doc/page.tsx Normal file
View file

@ -0,0 +1,11 @@
import { getApiDocs } from "@/lib/swagger";
import ReactSwagger from "./react-swagger";
export default async function IndexPage() {
const spec = await getApiDocs();
return (
<section className="container">
<ReactSwagger spec={spec} />
</section>
);
}

View file

@ -0,0 +1,14 @@
'use client';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
type Props = {
spec: object,
};
function ReactSwagger({ spec }: Props) {
return <SwaggerUI spec={spec} />;
}
export default ReactSwagger;

View file

@ -0,0 +1,212 @@
import { auth } from '@/auth';
import { NextResponse } from 'next/server';
import { prisma } from '@/prisma';
import {
userEmailSchema,
userFirstNameSchema,
userNameSchema,
userLastNameSchema,
} from '@/lib/validation/user';
/**
* @swagger
* /api/user/me:
* get:
* description: Retrieve the information of the currently authenticated user.
* responses:
* 200:
* description: User information retrieved successfully.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: User is not authenticated.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* {
* message: 'Not authenticated'
* }
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* {
* message: 'User not found'
* }
*/
export const GET = auth(async function GET(req) {
if (!req.auth)
return NextResponse.json({ message: 'Not authenticated' }, { status: 401 });
if (!req.auth.user || !req.auth.user.id)
return NextResponse.json({ message: 'User not found' }, { status: 404 });
const dbUser = await prisma.user.findUnique({
where: {
id: req.auth.user.id,
},
});
if (!dbUser)
return NextResponse.json({ message: 'User not found' }, { status: 404 });
return NextResponse.json(
{
...dbUser,
password_hash: undefined, // Exclude sensitive information
email_verified: undefined, // Exclude sensitive information
},
{ status: 200 },
);
});
/**
* @swagger
* /api/user/me:
* put:
* description: Update the information of the currently authenticated user.
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* description: Username of the user.
* first_name:
* type: string
* description: First name of the user.
* last_name:
* type: string
* description: Last name of the user.
* email:
* type: string
* description: Email address of the user.
* image:
* type: string
* description: URL of the user's profile image.
* timezone:
* type: string
* description: Timezone of the user.
* responses:
* 200:
* description: User information updated successfully.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/User'
* 401:
* description: User is not authenticated.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* {
* message: 'Not authenticated'
* }
* 404:
* description: User not found.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* {
* message: 'User not found'
* }
* 400:
* description: Bad request due to invalid input data.
* content:
* application/json:
* schema:
* $ref: '#/components/schemas/ErrorResponse'
* example:
* {
* message: 'No fields to update'
* }
*/
export const PUT = auth(async function PUT(req) {
if (!req.auth)
return NextResponse.json({ message: 'Not authenticated' }, { status: 401 });
if (!req.auth.user)
return NextResponse.json({ message: 'User not found' }, { status: 404 });
const body = await req.json();
const { name, first_name, last_name, email, image, timezone } = body;
if (!name && !first_name && !last_name && !email && !image && !timezone)
return NextResponse.json(
{ message: 'No fields to update' },
{ status: 400 },
);
const updateData: Record<string, string> = {};
if (name) {
const nameValidation = userNameSchema.safeParse(name);
if (!nameValidation.success) {
return NextResponse.json(
{ message: nameValidation.error.errors[0].message },
{ status: 400 },
);
}
updateData.name = name;
}
if (first_name) {
const firstNameValidation = userFirstNameSchema.safeParse(first_name);
if (!firstNameValidation.success) {
return NextResponse.json(
{ message: firstNameValidation.error.errors[0].message },
{ status: 400 },
);
}
updateData.first_name = first_name;
}
if (last_name) {
const lastNameValidation = userLastNameSchema.safeParse(last_name);
if (!lastNameValidation.success) {
return NextResponse.json(
{ message: lastNameValidation.error.errors[0].message },
{ status: 400 },
);
}
updateData.last_name = last_name;
}
if (email) {
const emailValidation = userEmailSchema.safeParse(email);
if (!emailValidation.success) {
return NextResponse.json(
{ message: emailValidation.error.errors[0].message },
{ status: 400 },
);
}
updateData.email = email;
}
if (image) {
updateData.image = image;
}
if (timezone) {
updateData.timezone = timezone;
}
const updatedUser = await prisma.user.update({
where: {
id: req.auth.user.id,
},
data: updateData,
});
if (!updatedUser)
return NextResponse.json({ message: 'User not found' }, { status: 404 });
return NextResponse.json(
{
...updatedUser,
password_hash: undefined, // Exclude sensitive information
email_verified: undefined, // Exclude sensitive information
},
{ status: 200 },
);
});

View file

@ -364,3 +364,8 @@
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
} }
/* Fix for swagger ui readability */
body:has(.swagger-ui) {
@apply bg-white text-black;
}

View file

@ -113,6 +113,18 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
authorized({ auth }) { authorized({ auth }) {
return !!auth?.user; return !!auth?.user;
}, },
session: async ({ session, token }) => {
if (session?.user) {
session.user.id = token.sub as string;
}
return session;
},
jwt: async ({ user, token }) => {
if (user) {
token.uid = user.id;
}
return token;
},
}, },
debug: process.env.NODE_ENV === 'development', debug: process.env.NODE_ENV === 'development',
}); });

50
src/lib/swagger.ts Normal file
View file

@ -0,0 +1,50 @@
import { createSwaggerSpec } from 'next-swagger-doc';
export const getApiDocs = async () => {
const spec = createSwaggerSpec({
apiFolder: 'src/app/api',
definition: {
openapi: '3.0.0',
info: {
title: 'MeetUP API',
version: '1.0',
},
// components: {
// securitySchemes: {
// BearerAuth: {
// type: "http",
// scheme: "bearer",
// bearerFormat: "JWT",
// },
// },
// },
components:{
schemas: {
ErrorResponse: {
type: 'object',
properties: {
message: {
type: 'string',
description: 'Error message',
},
},
},
User: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
first_name: { type: 'string' },
last_name: { type: 'string' },
email: { type: 'string', format: 'email' },
image: { type: 'string', format: 'uri' },
timezone: { type: 'string', description: 'User timezone' },
},
},
},
},
security: [],
},
});
return spec;
};

View file

@ -1,37 +1,39 @@
import zod from 'zod'; import zod from 'zod';
export const loginSchema = zod.object({ export const userEmailSchema = zod
email: zod
.string() .string()
.email('Invalid email address') .email('Invalid email address')
.min(3, 'Email is required') .min(3, 'Email is required');
.or(
zod export const userFirstNameSchema = zod
.string()
.min(1, 'First name is required')
.max(32, 'First name must be at most 32 characters long');
export const userLastNameSchema = zod
.string()
.min(1, 'Last name is required')
.max(32, 'Last name must be at most 32 characters long');
export const userNameSchema = zod
.string() .string()
.min(3, 'Username is required') .min(3, 'Username is required')
.max(32, 'Username must be at most 32 characters long') .max(32, 'Username must be at most 32 characters long')
.regex( .regex(
/^[a-zA-Z0-9_]+$/, /^[a-zA-Z0-9_]+$/,
'Username can only contain letters, numbers, and underscores', 'Username can only contain letters, numbers, and underscores',
), );
),
export const loginSchema = zod.object({
email: userEmailSchema.or(userNameSchema),
password: zod.string().min(1, 'Password is required'), password: zod.string().min(1, 'Password is required'),
}); });
export const registerSchema = zod export const registerSchema = zod
.object({ .object({
firstName: zod firstName: userFirstNameSchema,
.string() lastName: userLastNameSchema,
.min(1, 'First name is required') email: userEmailSchema,
.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 password: zod
.string() .string()
.min(8, 'Password must be at least 8 characters long') .min(8, 'Password must be at least 8 characters long')
@ -40,14 +42,7 @@ export const registerSchema = zod
.string() .string()
.min(8, 'Password must be at least 8 characters long') .min(8, 'Password must be at least 8 characters long')
.max(128, 'Password must be at most 128 characters long'), .max(128, 'Password must be at most 128 characters long'),
username: zod username: userNameSchema,
.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, { .refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match', message: 'Passwords do not match',

View file

@ -2,6 +2,6 @@ export { auth as middleware } from '@/auth';
export const config = { export const config = {
matcher: [ matcher: [
'/((?!api|_next/static|_next/image|site\.webmanifest|web-app-manifest-(?:192x192|512x512)\.png|favicon(?:-(?:dark|light))?\.(?:png|svg|ico)|fonts).*)', '/((?!api|_next/static|api-doc|_next/image|site\.webmanifest|web-app-manifest-(?:192x192|512x512)\.png|favicon(?:-(?:dark|light))?\.(?:png|svg|ico)|fonts).*)',
], ],
}; };

2873
yarn.lock

File diff suppressed because it is too large Load diff