Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions src/admin-ui/handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {CoreService, InMemoryDeploymentRepository} from '../core/index.ts';
import type {DesiredStateSnapshot} from '../shared/index.ts';

import {AdminHandler} from './handler.ts';

function makeCore() {
return new CoreService(new InMemoryDeploymentRepository());
}

const target = {
cloudProvider: 'aws' as const,
accountId: '123456789012',
projectId: 'kloak',
environment: 'prod',
};

const snapshot: DesiredStateSnapshot = {
deploymentId: 'placeholder',
versionId: 'placeholder',
keycloak: {
realmName: 'kloak',
clients: [],
roles: [],
groups: [],
users: [],
},
infrastructure: {
ingressHosts: ['kloak.example.com'],
dnsRecords: [],
secrets: [],
},
};

test('POST /deployments creates a deployment and returns 201', async () => {
const handler = new AdminHandler(makeCore());

const res = await handler.handle({
method: 'POST',
path: '/deployments',
params: {},
body: {customerId: 'cust-1', name: 'prod', target},
});

assert.equal(res.status, 201);
const deployment = res.body as {id: string; name: string; status: string};
assert.equal(deployment.name, 'prod');
assert.equal(deployment.status, 'draft');
});

test('GET /deployments returns all deployments', async () => {
const core = makeCore();
const handler = new AdminHandler(core);

await core.createDeployment({customerId: 'c1', name: 'a', target});
await core.createDeployment({customerId: 'c2', name: 'b', target});

const res = await handler.handle({
method: 'GET',
path: '/deployments',
params: {},
body: null,
});

assert.equal(res.status, 200);
assert.equal((res.body as unknown[]).length, 2);
});

test('GET /deployments/:id returns 404 for unknown id', async () => {
const handler = new AdminHandler(makeCore());

const res = await handler.handle({
method: 'GET',
path: '/deployments/:id',
params: {id: 'does-not-exist'},
body: null,
Comment on lines +74 to +78
});

assert.equal(res.status, 404);
});

test('GET /deployments/:id returns the deployment', async () => {
const core = makeCore();
const handler = new AdminHandler(core);

const created = await core.createDeployment({customerId: 'c1', name: 'prod', target});

const res = await handler.handle({
method: 'GET',
path: '/deployments/:id',
params: {id: created.id},
body: null,
});

assert.equal(res.status, 200);
assert.equal((res.body as {id: string}).id, created.id);
});

test('POST /deployments/:id/versions creates a version and returns 201', async () => {
const core = makeCore();
const handler = new AdminHandler(core);

const deployment = await core.createDeployment({customerId: 'c1', name: 'prod', target});
const versionSnapshot = {
...snapshot,
deploymentId: deployment.id,
versionId: crypto.randomUUID(),
};

const res = await handler.handle({
method: 'POST',
path: '/deployments/:id/versions',
params: {id: deployment.id},
body: {createdBy: 'operator', snapshot: versionSnapshot},
});

assert.equal(res.status, 201);
assert.equal((res.body as {number: number}).number, 1);
});

test('PUT /deployments/:id/status updates the deployment status', async () => {
const core = makeCore();
const handler = new AdminHandler(core);

const deployment = await core.createDeployment({customerId: 'c1', name: 'prod', target});

const res = await handler.handle({
method: 'PUT',
path: '/deployments/:id/status',
params: {id: deployment.id},
body: {status: 'healthy'},
});

assert.equal(res.status, 200);
assert.equal((res.body as {status: string}).status, 'healthy');
});

test('unknown route returns 404', async () => {
const handler = new AdminHandler(makeCore());

const res = await handler.handle({
method: 'GET',
path: '/unknown',
params: {},
body: null,
});

assert.equal(res.status, 404);
});
127 changes: 127 additions & 0 deletions src/admin-ui/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type {IncomingMessage, ServerResponse} from 'node:http';

import type {CoreApi, CreateDeploymentInput, CreateDeploymentVersionInput} from '../core/index.ts';
import type {DeploymentId, DeploymentStatus} from '../shared/index.ts';

export type HttpMethod = 'GET'|'POST'|'PUT'|'DELETE'|'PATCH';

export interface HttpRequest {
readonly method: HttpMethod;
readonly path: string;
readonly params: Record<string, string>;
readonly body: unknown;
}

export interface HttpResponse {
readonly status: number;
readonly body: unknown;
}

export class AdminHandler {
constructor(private readonly core: CoreApi) {}

async handle(req: HttpRequest): Promise<HttpResponse> {
const {method, path, params, body} = req;

if (method === 'POST' && path === '/deployments') {
const input = body as CreateDeploymentInput;
const deployment = await this.core.createDeployment(input);
return {status: 201, body: deployment};
}

if (method === 'GET' && path === '/deployments') {
const deployments = await this.core.listDeployments();
return {status: 200, body: deployments};
}

if (method === 'GET' && path === '/deployments/:id') {
const deployment = await this.core.getDeployment(params['id'] as DeploymentId);
if (deployment === null) {
return {status: 404, body: {error: 'Deployment not found'}};
}
return {status: 200, body: deployment};
}

if (method === 'POST' && path === '/deployments/:id/versions') {
const input = body as Omit<CreateDeploymentVersionInput, 'deploymentId'>;
const version = await this.core.createDeploymentVersion({
...input,
deploymentId: params['id'] as DeploymentId,
});
return {status: 201, body: version};
}
Comment on lines +45 to +52

if (method === 'PUT' && path === '/deployments/:id/status') {
const {status: newStatus} = body as {status: DeploymentStatus};
const deployment =
await this.core.markDeploymentStatus(params['id'] as DeploymentId, newStatus);
return {status: 200, body: deployment};
}
Comment on lines +54 to +59

return {status: 404, body: {error: 'Not found'}};
}
}

export function matchRoute(
reqPath: string,
pattern: string): Record<string, string>|null {
const patternParts = pattern.split('/');
const pathParts = reqPath.split('/');
if (patternParts.length !== pathParts.length) return null;

const params: Record<string, string> = {};
for (let i = 0; i < patternParts.length; i++) {
const seg = patternParts[i];
if (seg === undefined || pathParts[i] === undefined) return null;
if (seg.startsWith(':')) {
params[seg.slice(1)] = pathParts[i]!;
} else if (seg !== pathParts[i]) {
return null;
}
}
return params;
}

const ROUTES: Array<{method: HttpMethod; pattern: string}> = [
{method: 'POST', pattern: '/deployments'},
{method: 'GET', pattern: '/deployments'},
{method: 'GET', pattern: '/deployments/:id'},
{method: 'POST', pattern: '/deployments/:id/versions'},
{method: 'PUT', pattern: '/deployments/:id/status'},
];

export async function nodeHttpAdapter(
handler: AdminHandler,
req: IncomingMessage,
res: ServerResponse): Promise<void> {
const rawBody = await readBody(req);
const body = rawBody.length > 0 ? JSON.parse(rawBody) : null;
Comment on lines +97 to +98
const url = req.url ?? '/';
const method = (req.method ?? 'GET') as HttpMethod;

let matched: HttpRequest|null = null;
for (const route of ROUTES) {
if (route.method !== method) continue;
const params = matchRoute(url, route.pattern);
if (params !== null) {
matched = {method, path: route.pattern, params, body};
break;
}
}

const response = matched !== null ?
await handler.handle(matched) :
{status: 404, body: {error: 'Not found'}};

res.writeHead(response.status, {'Content-Type': 'application/json'});
res.end(JSON.stringify(response.body));
}

function readBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => resolve(Buffer.concat(chunks).toString()));
req.on('error', reject);
});
}