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
3 changes: 3 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
"name": "@agent-worth/api",
"version": "0.1.0",
"type": "module",
"imports": {
"#*": "./src/*"
},
"exports": {
".": "./src/app.ts"
},
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'bun:test';
import { syntheticTranscriptEvents } from '@agent-worth/shared';
import { createApp } from './app';
import { createMemoryRepository } from './repository';
import { createApp } from '#app.ts';
import { createMemoryRepository } from '#repositories/agent-worth.repository.ts';

describe('Agent Worth API', () => {
test('enrolls a daemon client with the development token', async () => {
Expand Down
95 changes: 25 additions & 70 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,35 @@
import { CloudEventSchema } from '@agent-worth/shared';
import { cors } from '@elysiajs/cors';
import { swagger } from '@elysiajs/swagger';
import { Elysia, t } from 'elysia';
import { createMemoryRepository, type Repository, type SessionView } from './repository';
import { Elysia } from 'elysia';
import { configuredMaxRequestBodySize } from '#lib/env.ts';
import { elysiaErrorHandler } from '#lib/errors.ts';
import { requestResponsePlugin } from '#lib/request-response.ts';
import {
type AgentWorthRepositoryContract,
createMemoryRepository,
} from '#repositories/agent-worth.repository.ts';
import { createCostsController } from '#routes/costs/controller.ts';
import { createEmployeeSummaryController } from '#routes/employees/summary/controller.ts';
import { createEnrollController } from '#routes/enroll/controller.ts';
import { createHealthController } from '#routes/health/controller.ts';
import { createIngestBatchController } from '#routes/ingest/batch/controller.ts';
import { createSessionsController } from '#routes/sessions/controller.ts';
import { createServicePlugins } from '#services/plugins.ts';

const DEFAULT_MAX_REQUEST_BODY_SIZE = 512 * 1024 * 1024;
export function createApp(repository: AgentWorthRepositoryContract = createMemoryRepository()) {
const servicePlugins = createServicePlugins(repository);

function configuredMaxRequestBodySize() {
const parsed = Number(Bun.env.AGENT_WORTH_MAX_REQUEST_BODY_SIZE ?? DEFAULT_MAX_REQUEST_BODY_SIZE);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_REQUEST_BODY_SIZE;
}

function sessionListItem({ messages: _messages, ...session }: SessionView) {
return session;
}

export function createApp(repository: Repository = createMemoryRepository()) {
return new Elysia({
serve: {
maxRequestBodySize: configuredMaxRequestBodySize(),
},
})
.onError(elysiaErrorHandler)
.onParse(({ contentType, request }) => {
if (contentType === 'application/cloudevents-batch+json') return request.json();
})
.use(cors())
.use(requestResponsePlugin)
.use(
swagger({
documentation: {
Expand All @@ -35,62 +40,12 @@ export function createApp(repository: Repository = createMemoryRepository()) {
},
}),
)
.decorate('repository', repository)
.get('/health', () => ({ ok: true }))
.post('/v1/enroll', async ({ body, repository: repo }) => repo.enroll(body), {
body: t.Object({
enrollmentToken: t.String(),
clientId: t.String(),
hostnameHash: t.Optional(t.String()),
}),
})
.post(
'/v1/ingest/batch',
async ({ body, headers, repository: repo }) => {
const events = body.map((event) => CloudEventSchema.parse(event));
return repo.ingestBatch(events, headers.authorization);
},
{
body: t.Array(t.Any()),
},
)
.get(
'/v1/sessions',
({ query, repository: repo }) =>
repo
.listSessions({
employeeId: query.employeeId,
sourceTool: query.sourceTool,
day: query.day,
usageStatus: query.usageStatus,
})
.map(sessionListItem),
{
query: t.Object({
employeeId: t.Optional(t.String()),
sourceTool: t.Optional(t.String()),
day: t.Optional(t.String()),
usageStatus: t.Optional(t.String()),
}),
},
)
.get(
'/v1/costs',
({ query, repository: repo }) =>
repo.costSummary({
day: query.day,
employeeId: query.employeeId,
}),
{
query: t.Object({
day: t.Optional(t.String()),
employeeId: t.Optional(t.String()),
}),
},
)
.get('/v1/employees/:id/summary', ({ params, repository: repo }) =>
repo.employeeSummary(params.id),
);
.use(createHealthController(servicePlugins))
.use(createEnrollController(servicePlugins))
.use(createIngestBatchController(servicePlugins))
.use(createSessionsController(servicePlugins))
.use(createCostsController(servicePlugins))
.use(createEmployeeSummaryController(servicePlugins));
}

export type App = ReturnType<typeof createApp>;
6 changes: 4 additions & 2 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { createApp } from './app';
import { createApp } from '#app.ts';
import { createLogger } from '#lib/logger.ts';

const port = Number(Bun.env.PORT ?? 3001);
const logger = createLogger('agent-worth');

createApp().listen(port, ({ hostname, port: actualPort }) => {
console.log(`agent-worth api listening at http://${hostname}:${actualPort}`);
logger.info(`api listening at http://${hostname}:${actualPort}`);
});
6 changes: 6 additions & 0 deletions apps/api/src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
const DEFAULT_MAX_REQUEST_BODY_SIZE = 512 * 1024 * 1024;

export function configuredMaxRequestBodySize() {
const parsed = Number(Bun.env.AGENT_WORTH_MAX_REQUEST_BODY_SIZE ?? DEFAULT_MAX_REQUEST_BODY_SIZE);
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_REQUEST_BODY_SIZE;
}
49 changes: 49 additions & 0 deletions apps/api/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { type ErrorHandler, StatusMap } from 'elysia';
import { createLogger } from '#lib/logger.ts';

const errorLogger = createLogger();

export class AppError extends Error {
readonly statusCode: number;

constructor(statusCode: number, message: string) {
super(message);
this.name = 'AppError';
this.statusCode = statusCode;
}
}

export class BadRequestError extends AppError {
constructor(message = 'Bad Request') {
super(StatusMap['Bad Request'], message);
this.name = 'BadRequestError';
}
}

export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(StatusMap.Unauthorized, message);
this.name = 'UnauthorizedError';
}
}

type ErrorHandlerOptions = Parameters<ErrorHandler>[0];
type ErrorHandlerResult = ReturnType<ErrorHandler>;

export function elysiaErrorHandler({
error,
code,
status,
}: ErrorHandlerOptions): ErrorHandlerResult {
errorLogger.error(code, error);
if (error instanceof AppError) {
return status(error.statusCode, { error: error.message });
}
if (code === 'VALIDATION') {
return status(StatusMap['Bad Request'], { error: 'Validation error', details: error.message });
}
if (code === 'NOT_FOUND') {
return status(StatusMap['Not Found'], { error: 'Not Found' });
}
return status(StatusMap['Internal Server Error'], { error: 'Internal server error' });
}
37 changes: 37 additions & 0 deletions apps/api/src/lib/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
const RESET = '\x1b[0m';
const LABEL_WIDTH = 5;

const LEVEL_STYLES: Record<string, string> = {
debug: '\x1b[36m',
info: '\x1b[32m',
warn: '\x1b[33m',
error: '\x1b[31m',
};

type LogLevel = 'debug' | 'info' | 'warn' | 'error';

function write(level: LogLevel, prefix: string, ...args: unknown[]): void {
const ts = new Date().toISOString();
const style = LEVEL_STYLES[level] ?? '';
const tag = `${style}${level.toUpperCase().padEnd(LABEL_WIDTH)}${RESET}`;
const out = level === 'error' ? console.error : console.log;
const head = prefix ? `${ts} ${tag} [${prefix}]` : `${ts} ${tag}`;
out(head, ...args);
}

export function createLogger(prefix = '') {
return {
debug(...args: unknown[]) {
write('debug', prefix, ...args);
},
info(...args: unknown[]) {
write('info', prefix, ...args);
},
warn(...args: unknown[]) {
write('warn', prefix, ...args);
},
error(...args: unknown[]) {
write('error', prefix, ...args);
},
};
}
28 changes: 28 additions & 0 deletions apps/api/src/lib/request-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Elysia, StatusMap } from 'elysia';
import { createLogger } from '#lib/logger.ts';

const httpLogger = createLogger('http');

function formatRequest(request: Request): string {
return `${request.method} ${new URL(request.url).pathname}`;
}

function resolveStatus(status: number | keyof StatusMap | undefined): number {
return typeof status === 'string' ? StatusMap[status] : (status ?? StatusMap.OK);
}

function formatElapsed(startedAt: number | undefined): string {
return startedAt === undefined ? '' : ` (${Math.round(performance.now() - startedAt)}ms)`;
}

export const requestResponsePlugin = new Elysia({ name: 'request-response' })
.derive(() => ({ requestStartedAt: performance.now() }))
.onRequest(({ request }) => {
httpLogger.info(`-> ${formatRequest(request)}`);
})
.onAfterResponse(({ request, set, requestStartedAt }) => {
const status = resolveStatus(set.status);
const elapsed = formatElapsed(requestStartedAt);
httpLogger.info(`<- ${formatRequest(request)} ${status}${elapsed}`);
})
.as('global');
Loading