Skip to content

Commit 9e7a781

Browse files
committed
Add layered enterprise API architecture
1 parent faa4ca1 commit 9e7a781

39 files changed

Lines changed: 2006 additions & 559 deletions

dist/templates/api/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,38 @@ Send access tokens using:
8787

8888
Never use the default JWT secrets in production.
8989

90+
## Application Architecture
91+
92+
The generated API uses a modular layered architecture:
93+
94+
src/
95+
core/
96+
errors/
97+
http/
98+
types/
99+
modules/
100+
auth/
101+
auth.controller.ts
102+
auth.repository.ts
103+
auth.routes.ts
104+
auth.schemas.ts
105+
auth.service.ts
106+
auth.types.ts
107+
users/
108+
user.controller.ts
109+
user.dto.ts
110+
user.repository.ts
111+
user.service.ts
112+
113+
Layer responsibilities:
114+
115+
- Routes define HTTP endpoints and middleware.
116+
- Controllers translate HTTP requests into service calls.
117+
- Services contain application and domain logic.
118+
- Repositories isolate Prisma database access.
119+
- DTOs control data exposed by the API.
120+
- Core utilities provide shared errors, response envelopes, and pagination.
121+
90122
## Quality Commands
91123

92124
Run tests:
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export type ApplicationErrorOptions = {
2+
statusCode: number;
3+
code: string;
4+
message: string;
5+
details?: unknown;
6+
};
7+
8+
export class ApplicationError extends Error {
9+
readonly statusCode: number;
10+
readonly code: string;
11+
readonly details?: unknown;
12+
13+
constructor(options: ApplicationErrorOptions) {
14+
super(options.message);
15+
16+
this.name = "ApplicationError";
17+
this.statusCode = options.statusCode;
18+
this.code = options.code;
19+
this.details = options.details;
20+
}
21+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const ErrorCode = {
2+
ValidationFailed: "VALIDATION_FAILED",
3+
AuthenticationRequired: "AUTHENTICATION_REQUIRED",
4+
InvalidCredentials: "INVALID_CREDENTIALS",
5+
ResourceNotFound: "RESOURCE_NOT_FOUND",
6+
ResourceConflict: "RESOURCE_CONFLICT",
7+
Forbidden: "FORBIDDEN",
8+
InternalError: "INTERNAL_ERROR"
9+
} as const;
10+
11+
export type ErrorCode =
12+
(typeof ErrorCode)[keyof typeof ErrorCode];
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export type ApiSuccessResponse<T> = {
2+
data: T;
3+
meta?: Record<string, unknown>;
4+
};
5+
6+
export function successResponse<T>(
7+
data: T,
8+
meta?: Record<string, unknown>
9+
): ApiSuccessResponse<T> {
10+
return meta
11+
? {
12+
data,
13+
meta
14+
}
15+
: {
16+
data
17+
};
18+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export type PaginationInput = {
2+
page: number;
3+
limit: number;
4+
};
5+
6+
export type PaginationMeta = {
7+
page: number;
8+
limit: number;
9+
total: number;
10+
totalPages: number;
11+
};
12+
13+
export function createPaginationMeta(
14+
input: PaginationInput,
15+
total: number
16+
): PaginationMeta {
17+
return {
18+
page: input.page,
19+
limit: input.limit,
20+
total,
21+
totalPages:
22+
total === 0
23+
? 0
24+
: Math.ceil(total / input.limit)
25+
};
26+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type {
2+
FastifyReply,
3+
FastifyRequest
4+
} from "fastify";
5+
import {
6+
successResponse
7+
} from "../../core/http/api-response";
8+
import {
9+
AuthService
10+
} from "./auth.service";
11+
import type {
12+
LoginInput,
13+
RefreshInput,
14+
RegisterInput
15+
} from "./auth.types";
16+
17+
export async function registerController(
18+
request: FastifyRequest<{
19+
Body: RegisterInput;
20+
}>,
21+
reply: FastifyReply
22+
): Promise<FastifyReply> {
23+
const service =
24+
new AuthService(request.server);
25+
26+
const result = await service.register(
27+
request.body
28+
);
29+
30+
return reply.status(201).send(
31+
successResponse(result)
32+
);
33+
}
34+
35+
export async function loginController(
36+
request: FastifyRequest<{
37+
Body: LoginInput;
38+
}>,
39+
reply: FastifyReply
40+
): Promise<FastifyReply> {
41+
const service =
42+
new AuthService(request.server);
43+
44+
const result = await service.login(
45+
request.body
46+
);
47+
48+
return reply.send(
49+
successResponse(result)
50+
);
51+
}
52+
53+
export async function refreshController(
54+
request: FastifyRequest<{
55+
Body: RefreshInput;
56+
}>,
57+
reply: FastifyReply
58+
): Promise<FastifyReply> {
59+
const service =
60+
new AuthService(request.server);
61+
62+
const tokens = await service.refresh(
63+
request.body.refreshToken
64+
);
65+
66+
return reply.send(
67+
successResponse(tokens)
68+
);
69+
}
70+
71+
export async function logoutController(
72+
request: FastifyRequest<{
73+
Body: RefreshInput;
74+
}>,
75+
reply: FastifyReply
76+
): Promise<FastifyReply> {
77+
const service =
78+
new AuthService(request.server);
79+
80+
await service.logout(
81+
request.body.refreshToken
82+
);
83+
84+
return reply.status(204).send();
85+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type {
2+
PrismaClient,
3+
RefreshToken,
4+
User
5+
} from "@prisma/client";
6+
7+
export class AuthRepository {
8+
constructor(
9+
private readonly prisma: PrismaClient
10+
) {}
11+
12+
findUserByEmail(email: string): Promise<User | null> {
13+
return this.prisma.user.findUnique({
14+
where: {
15+
email
16+
}
17+
});
18+
}
19+
20+
createUser(input: {
21+
email: string;
22+
name: string | null;
23+
passwordHash: string;
24+
}): Promise<User> {
25+
return this.prisma.user.create({
26+
data: input
27+
});
28+
}
29+
30+
createRefreshToken(input: {
31+
tokenHash: string;
32+
userId: string;
33+
expiresAt: Date;
34+
}): Promise<RefreshToken> {
35+
return this.prisma.refreshToken.create({
36+
data: input
37+
});
38+
}
39+
40+
findRefreshTokenWithUser(tokenHash: string) {
41+
return this.prisma.refreshToken.findUnique({
42+
where: {
43+
tokenHash
44+
},
45+
include: {
46+
user: true
47+
}
48+
});
49+
}
50+
51+
revokeRefreshToken(id: string): Promise<RefreshToken> {
52+
return this.prisma.refreshToken.update({
53+
where: {
54+
id
55+
},
56+
data: {
57+
revokedAt: new Date()
58+
}
59+
});
60+
}
61+
62+
revokeRefreshTokensByHash(
63+
tokenHash: string
64+
): Promise<{
65+
count: number;
66+
}> {
67+
return this.prisma.refreshToken.updateMany({
68+
where: {
69+
tokenHash,
70+
revokedAt: null
71+
},
72+
data: {
73+
revokedAt: new Date()
74+
}
75+
});
76+
}
77+
}
Lines changed: 61 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,78 @@
1-
import type { FastifyPluginAsync } from "fastify";
2-
import { loginSchema, logoutSchema, meSchema, refreshSchema, registerSchema } from "./auth.schemas";
3-
import { loginUser, refreshUserTokens, registerUser, revokeRefreshToken } from "./auth.service";
4-
import type { LoginInput, RefreshInput, RegisterInput } from "./auth.types";
1+
import type {
2+
FastifyPluginAsync
3+
} from "fastify";
4+
import {
5+
getCurrentUserController
6+
} from "../users/user.controller";
7+
import {
8+
loginController,
9+
logoutController,
10+
refreshController,
11+
registerController
12+
} from "./auth.controller";
13+
import {
14+
loginSchema,
15+
logoutSchema,
16+
meSchema,
17+
refreshSchema,
18+
registerSchema
19+
} from "./auth.schemas";
20+
import type {
21+
LoginInput,
22+
RefreshInput,
23+
RegisterInput
24+
} from "./auth.types";
525

6-
export const authRoutes: FastifyPluginAsync = async (app) => {
7-
app.post<{ Body: RegisterInput }>(
26+
export const authRoutes:
27+
FastifyPluginAsync = async (app) => {
28+
app.post<{
29+
Body: RegisterInput;
30+
}>(
831
"/register",
9-
{ schema: registerSchema },
10-
async (request, reply) => {
11-
const result = await registerUser(app, request.body);
12-
return reply.status(201).send(result);
13-
}
32+
{
33+
schema: registerSchema
34+
},
35+
registerController
1436
);
1537

16-
app.post<{ Body: LoginInput }>(
38+
app.post<{
39+
Body: LoginInput;
40+
}>(
1741
"/login",
18-
{ schema: loginSchema },
19-
async (request) => loginUser(app, request.body)
42+
{
43+
schema: loginSchema
44+
},
45+
loginController
2046
);
2147

22-
app.post<{ Body: RefreshInput }>(
48+
app.post<{
49+
Body: RefreshInput;
50+
}>(
2351
"/refresh",
24-
{ schema: refreshSchema },
25-
async (request) => refreshUserTokens(app, request.body.refreshToken)
52+
{
53+
schema: refreshSchema
54+
},
55+
refreshController
2656
);
2757

28-
app.post<{ Body: RefreshInput }>(
58+
app.post<{
59+
Body: RefreshInput;
60+
}>(
2961
"/logout",
30-
{ schema: logoutSchema },
31-
async (request, reply) => {
32-
await revokeRefreshToken(app, request.body.refreshToken);
33-
return reply.status(204).send();
34-
}
62+
{
63+
schema: logoutSchema
64+
},
65+
logoutController
3566
);
3667

3768
app.get(
3869
"/me",
39-
{ schema: meSchema, preHandler: [app.authenticate] },
40-
async (request) => app.prisma.user.findUniqueOrThrow({
41-
where: { id: request.user.sub },
42-
select: {
43-
id: true,
44-
email: true,
45-
name: true,
46-
role: true,
47-
createdAt: true,
48-
updatedAt: true
49-
}
50-
})
70+
{
71+
schema: meSchema,
72+
preHandler: [
73+
app.authenticate
74+
]
75+
},
76+
getCurrentUserController
5177
);
5278
};

0 commit comments

Comments
 (0)