Enable auth-service/api-gateway on production, build their images in CI

This commit is contained in:
2026-08-18 14:11:00 +07:00
parent e5afedfa2f
commit b68005be1c
70 changed files with 6781 additions and 263 deletions
+5
View File
@@ -0,0 +1,5 @@
# apps/api-gateway configuration. Copy to `.env` and edit for local dev:
# cp apps/api-gateway/.env.example apps/api-gateway/.env
PORT=3000
AUTH_SERVICE_URL=http://localhost:3010
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../packages/config/eslint-preset/index.js"
}
+21
View File
@@ -0,0 +1,21 @@
FROM node:20-slim AS base
RUN corepack enable
WORKDIR /repo
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/api-gateway/package.json apps/api-gateway/package.json
COPY packages/config/package.json packages/config/package.json
RUN pnpm install --frozen-lockfile
FROM deps AS build
COPY packages/ packages/
COPY apps/api-gateway/ apps/api-gateway/
RUN pnpm --filter @duoc-thu/api-gateway build
FROM base AS runtime
ENV NODE_ENV=production
COPY --from=build /repo /repo
WORKDIR /repo/apps/api-gateway
EXPOSE 3000
CMD ["node", "dist/main.js"]
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('jest').Config} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
rootDir: ".",
testMatch: ["<rootDir>/test/**/*.spec.ts"],
};
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+30 -1
View File
@@ -1,5 +1,34 @@
{
"name": "@duoc-thu/api-gateway",
"private": true,
"version": "0.0.0"
"version": "0.0.0",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"lint": "eslint \"src/**/*.ts\" --max-warnings=0",
"test": "jest"
},
"dependencies": {
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/platform-express": "^10.4.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@duoc-thu/config": "workspace:*",
"@nestjs/cli": "^10.4.0",
"@nestjs/testing": "^10.4.0",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.0"
}
}
+7
View File
@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { ProxyModule } from "./proxy/proxy.module";
@Module({
imports: [ProxyModule],
})
export class AppModule {}
+11
View File
@@ -0,0 +1,11 @@
export interface Settings {
port: number;
authServiceUrl: string;
}
export function loadSettings(): Settings {
return {
port: Number(process.env.PORT ?? 3000),
authServiceUrl: process.env.AUTH_SERVICE_URL ?? "http://localhost:3010",
};
}
+12
View File
@@ -0,0 +1,12 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { loadSettings } from "./config";
async function bootstrap() {
const settings = loadSettings();
const app = await NestFactory.create(AppModule);
await app.listen(settings.port, "0.0.0.0");
}
bootstrap();
@@ -0,0 +1,38 @@
import { All, Controller, Req, Res } from "@nestjs/common";
import type { Request, Response } from "express";
import { loadSettings } from "../config";
const settings = loadSettings();
/**
* Thin proxy: forwards `/auth/*` to auth-service verbatim (method, body,
* `Authorization` header) and relays the response back unchanged. This is
* intentionally the ENTIRE gateway surface for this pass — chat/history/
* feedback/sections keep going straight from `apps/web`'s BFF to
* `ai-service`, not through here (see the plan this was built from). Growing
* this into the README's full "single public entry point" is future work,
* not done by accident just because this file exists.
*/
@Controller("auth")
export class AuthProxyController {
@All("*")
async proxy(@Req() req: Request, @Res() res: Response): Promise<void> {
const target = `${settings.authServiceUrl}${req.originalUrl}`;
const hasBody = req.method !== "GET" && req.method !== "HEAD";
const upstream = await fetch(target, {
method: req.method,
headers: {
"Content-Type": "application/json",
...(req.headers.authorization
? { Authorization: req.headers.authorization }
: {}),
},
body: hasBody ? JSON.stringify(req.body ?? {}) : undefined,
});
const payload = await upstream.text();
res
.status(upstream.status)
.setHeader("Content-Type", upstream.headers.get("content-type") ?? "application/json")
.send(payload);
}
}
@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { AuthProxyController } from "./auth-proxy.controller";
@Module({
controllers: [AuthProxyController],
})
export class ProxyModule {}
@@ -0,0 +1,81 @@
import "reflect-metadata";
describe("AuthProxyController", () => {
const originalFetch = global.fetch;
let AuthProxyController: typeof import("../src/proxy/auth-proxy.controller").AuthProxyController;
beforeAll(async () => {
// `../src/config.ts` reads this at module-import time, so it must be set
// before the dynamic import below, not in a plain top-of-file import.
process.env.AUTH_SERVICE_URL = "http://auth-service.test";
({ AuthProxyController } = await import("../src/proxy/auth-proxy.controller"));
});
afterAll(() => {
global.fetch = originalFetch;
});
it("forwards method, body, and Authorization header to auth-service and relays the response verbatim", async () => {
const mockFetch = jest.fn().mockResolvedValue({
status: 201,
headers: new Headers({ "content-type": "application/json" }),
text: async () => JSON.stringify({ username: "demo", role: "user" }),
});
global.fetch = mockFetch as unknown as typeof fetch;
const controller = new AuthProxyController();
const req = {
method: "POST",
originalUrl: "/auth/register",
headers: { authorization: "Bearer abc" },
body: { username: "demo", password: "1" },
} as unknown as import("express").Request;
const res = {
status: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
send: jest.fn(),
} as unknown as import("express").Response;
await controller.proxy(req, res);
expect(mockFetch).toHaveBeenCalledWith(
"http://auth-service.test/auth/register",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({ Authorization: "Bearer abc" }),
body: JSON.stringify({ username: "demo", password: "1" }),
})
);
expect(res.status).toHaveBeenCalledWith(201);
expect(res.send).toHaveBeenCalledWith(JSON.stringify({ username: "demo", role: "user" }));
});
it("sends no body for a GET (e.g. /auth/me)", async () => {
const mockFetch = jest.fn().mockResolvedValue({
status: 200,
headers: new Headers({ "content-type": "application/json" }),
text: async () => JSON.stringify({ username: "demo", role: "user" }),
});
global.fetch = mockFetch as unknown as typeof fetch;
const controller = new AuthProxyController();
const req = {
method: "GET",
originalUrl: "/auth/me",
headers: { authorization: "Bearer abc" },
body: {},
} as unknown as import("express").Request;
const res = {
status: jest.fn().mockReturnThis(),
setHeader: jest.fn().mockReturnThis(),
send: jest.fn(),
} as unknown as import("express").Response;
await controller.proxy(req, res);
expect(mockFetch).toHaveBeenCalledWith(
"http://auth-service.test/auth/me",
expect.objectContaining({ method: "GET", body: undefined })
);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../packages/config/tsconfig-base.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false
},
"include": ["src"]
}