Enable auth-service/api-gateway on production, build their images in CI
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../packages/config/eslint-preset/index.js"
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
rootDir: ".",
|
||||
testMatch: ["<rootDir>/test/**/*.spec.ts"],
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ProxyModule } from "./proxy/proxy.module";
|
||||
|
||||
@Module({
|
||||
imports: [ProxyModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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 })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user