diff --git a/docs/TODO.md b/docs/TODO.md index f6ba1b1..a1a0df6 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -44,13 +44,13 @@ ## 2. Next Up (Phase 1: Read/Browse Implementation) -- [ ] **Task 1: OpenProject REST API v3 Client Layer** - - [ ] Implement `src/config/index.ts` to parse and validate `OPENPROJECT_BASE_URL`, `OPENPROJECT_API_KEY`, and `OPENPROJECT_READ_ONLY` (or `--read-only`) via Zod. - - [ ] Implement `src/context.ts` providing `RequestContext` interface and `AsyncLocalStorage` scoping (`getRequestContext`, `runWithContext`) for concurrent multi-user safety. - - [ ] Implement `src/client/api-client.ts` with native `fetch`, HTTP Basic Auth injection, error normalization, and rate/timeout handling. - - [ ] Implement `src/client/hal-parser.ts` to unpack HAL+JSON (`_links`, `_embedded`) into clean, token-efficient JSON models. - - [ ] Implement `src/client/filter-builder.ts` to translate LLM-friendly filter arguments into OpenProject's JSON filter syntax. - - [ ] Add unit tests in `tests/client.test.ts` verifying authentication headers, response parsing, and error sanitization against live container. +- [x] **Task 1: OpenProject REST API v3 Client Layer** + - [x] Implement `src/config/index.ts` to parse and validate `OPENPROJECT_BASE_URL`, `OPENPROJECT_API_KEY`, and `OPENPROJECT_READ_ONLY` (or `--read-only`) via Zod. + - [x] Implement `src/context.ts` providing `RequestContext` interface and `AsyncLocalStorage` scoping (`getRequestContext`, `runWithContext`) for concurrent multi-user safety. + - [x] Implement `src/client/api-client.ts` with native `fetch`, HTTP Basic Auth injection, error normalization, and rate/timeout handling. + - [x] Implement `src/client/hal-parser.ts` to unpack HAL+JSON (`_links`, `_embedded`) into clean, token-efficient JSON models. + - [x] Implement `src/client/filter-builder.ts` to translate LLM-friendly filter arguments into OpenProject's JSON filter syntax. + - [x] Add unit tests in `tests/client.test.ts` verifying authentication headers, response parsing, and error sanitization against live container. - [ ] **Task 2: Domain Services** - [ ] Implement `src/services/projects.ts` (`listProjects`, `getProject`). diff --git a/src/client/api-client.ts b/src/client/api-client.ts new file mode 100644 index 0000000..02ccec7 --- /dev/null +++ b/src/client/api-client.ts @@ -0,0 +1,332 @@ +/** + * OpenProject REST API v3 HTTP Client. + */ + +import type { OpenProjectApiErrorPayload } from "./types.ts"; + +export interface ClientOptions { + baseUrl: string; + apiKey: string; + timeoutMs?: number; + fetchFn?: (input: string | URL | Request, init?: RequestInit) => Promise; +} + +// ============================================================================= +// Error Hierarchy +// ============================================================================= + +export class OpenProjectError extends Error { + readonly statusCode?: number; + readonly errorIdentifier?: string; + readonly details?: unknown; + + constructor( + message: string, + options?: { + statusCode?: number; + errorIdentifier?: string; + details?: unknown; + cause?: unknown; + } + ) { + super(message); + this.name = "OpenProjectError"; + this.statusCode = options?.statusCode; + this.errorIdentifier = options?.errorIdentifier; + this.details = options?.details; + if (options?.cause) { + this.cause = options.cause; + } + } +} + +export class OpenProjectAuthenticationError extends OpenProjectError { + constructor(message = "Authentication failed. Please check your OpenProject API key.", options?: { errorIdentifier?: string; details?: unknown; cause?: unknown }) { + super(message, { statusCode: 401, ...options }); + this.name = "OpenProjectAuthenticationError"; + } +} + +export class OpenProjectForbiddenError extends OpenProjectError { + constructor(message = "Access forbidden. You do not have permission to access this resource.", options?: { errorIdentifier?: string; details?: unknown; cause?: unknown }) { + super(message, { statusCode: 403, ...options }); + this.name = "OpenProjectForbiddenError"; + } +} + +export class OpenProjectNotFoundError extends OpenProjectError { + constructor(message = "Resource not found.", options?: { errorIdentifier?: string; details?: unknown; cause?: unknown }) { + super(message, { statusCode: 404, ...options }); + this.name = "OpenProjectNotFoundError"; + } +} + +export class OpenProjectConflictError extends OpenProjectError { + constructor(message = "Conflict updating resource. Lock version mismatch.", options?: { errorIdentifier?: string; details?: unknown; cause?: unknown }) { + super(message, { statusCode: 409, ...options }); + this.name = "OpenProjectConflictError"; + } +} + +export class OpenProjectValidationError extends OpenProjectError { + constructor(message = "Validation failed for request data.", options?: { errorIdentifier?: string; details?: unknown; cause?: unknown }) { + super(message, { statusCode: 422, ...options }); + this.name = "OpenProjectValidationError"; + } +} + +export class OpenProjectRateLimitError extends OpenProjectError { + readonly retryAfter?: number; + + constructor(message = "Rate limit exceeded. Please wait before retrying.", options?: { retryAfter?: number; errorIdentifier?: string; details?: unknown; cause?: unknown }) { + super(message, { statusCode: 429, ...options }); + this.name = "OpenProjectRateLimitError"; + this.retryAfter = options?.retryAfter; + } +} + +export class OpenProjectServerError extends OpenProjectError { + constructor(message = "OpenProject internal server error.", options?: { statusCode?: number; errorIdentifier?: string; details?: unknown; cause?: unknown }) { + super(message, { statusCode: options?.statusCode ?? 500, ...options }); + this.name = "OpenProjectServerError"; + } +} + +export class OpenProjectNetworkError extends OpenProjectError { + constructor(message = "Network error connecting to OpenProject instance.", options?: { cause?: unknown }) { + super(message, options); + this.name = "OpenProjectNetworkError"; + } +} + +// ============================================================================= +// OpenProject Client Implementation +// ============================================================================= + +export class OpenProjectClient { + readonly baseUrl: string; + private readonly apiKey: string; + private readonly timeoutMs: number; + private readonly fetchFn: (input: string | URL | Request, init?: RequestInit) => Promise; + private readonly authHeader: string; + + constructor(options: ClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.apiKey = options.apiKey; + this.timeoutMs = options.timeoutMs ?? 30000; + this.fetchFn = options.fetchFn ?? fetch; + this.authHeader = `Basic ${btoa(`apikey:${this.apiKey}`)}`; + } + + /** + * Performs an HTTP GET request to OpenProject API v3. + */ + async get( + path: string, + query?: Record + ): Promise { + const url = this.resolveUrl(path, query); + return this.request(url, { method: "GET" }); + } + + /** + * Performs an HTTP POST request to OpenProject API v3. + */ + async post(path: string, body?: unknown): Promise { + const url = this.resolveUrl(path); + return this.request(url, { + method: "POST", + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } + + /** + * Performs an HTTP PATCH request to OpenProject API v3. + */ + async patch(path: string, body?: unknown): Promise { + const url = this.resolveUrl(path); + return this.request(url, { + method: "PATCH", + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } + + /** + * Performs an HTTP DELETE request to OpenProject API v3. + */ + async delete(path: string): Promise { + const url = this.resolveUrl(path); + return this.request(url, { method: "DELETE" }); + } + + /** + * Resolves a path or full URL against the client's baseUrl and appends query parameters. + */ + resolveUrl( + path: string, + query?: Record + ): string { + let resolved: string; + + if (path.startsWith("http://") || path.startsWith("https://")) { + resolved = path; + } else { + let normalizedPath = path.startsWith("/") ? path : `/${path}`; + if (!normalizedPath.startsWith("/api/v3")) { + normalizedPath = `/api/v3${normalizedPath}`; + } + resolved = `${this.baseUrl}${normalizedPath}`; + } + + if (!query) { + return resolved; + } + + const url = new URL(resolved); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + } + return url.toString(); + } + + /** + * Sanitizes strings (e.g. error messages) to ensure credentials are never leaked. + */ + private sanitize(text: string): string { + if (!this.apiKey) { + return text; + } + let sanitized = text.replaceAll(this.apiKey, "[REDACTED_API_KEY]"); + const base64Auth = btoa(`apikey:${this.apiKey}`); + sanitized = sanitized.replaceAll(base64Auth, "[REDACTED_AUTH]"); + return sanitized; + } + + /** + * Executes an HTTP request with timeout, authentication, and error normalization. + */ + private async request(url: string, init: RequestInit): Promise { + const headers = new Headers(init.headers); + headers.set("Authorization", this.authHeader); + headers.set("Accept", "application/hal+json, application/json"); + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + let signal = init.signal; + let timeoutId: ReturnType | undefined; + + if (!signal) { + if (typeof AbortSignal !== "undefined" && "timeout" in AbortSignal) { + signal = AbortSignal.timeout(this.timeoutMs); + } else { + const controller = new AbortController(); + timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + signal = controller.signal; + } + } + + try { + const response = await this.fetchFn(url, { + ...init, + headers, + signal, + }); + + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (response.status === 204) { + return undefined as unknown as T; + } + + const contentType = response.headers.get("content-type") || ""; + const isJson = contentType.includes("application/json") || contentType.includes("application/hal+json"); + + if (!response.ok) { + let errorPayload: OpenProjectApiErrorPayload | undefined; + let rawErrorBody: string | undefined; + + if (isJson) { + try { + errorPayload = (await response.json()) as OpenProjectApiErrorPayload; + } catch { + // Ignore JSON parse failure on error body + } + } else { + try { + rawErrorBody = await response.text(); + } catch { + // Ignore text parse failure + } + } + + const message = errorPayload?.message + ? this.sanitize(errorPayload.message) + : rawErrorBody + ? this.sanitize(rawErrorBody.slice(0, 300)) + : `HTTP ${response.status} ${response.statusText}`; + + const errorIdentifier = errorPayload?.errorIdentifier; + const details = errorPayload?.errors; + + switch (response.status) { + case 401: + throw new OpenProjectAuthenticationError(message, { errorIdentifier, details }); + case 403: + throw new OpenProjectForbiddenError(message, { errorIdentifier, details }); + case 404: + throw new OpenProjectNotFoundError(message, { errorIdentifier, details }); + case 409: + throw new OpenProjectConflictError(message, { errorIdentifier, details }); + case 422: + throw new OpenProjectValidationError(message, { errorIdentifier, details }); + case 429: { + const retryAfterHeader = response.headers.get("retry-after"); + const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : undefined; + throw new OpenProjectRateLimitError(message, { retryAfter, errorIdentifier, details }); + } + default: + if (response.status >= 500) { + throw new OpenProjectServerError(message, { statusCode: response.status, errorIdentifier, details }); + } + throw new OpenProjectError(message, { statusCode: response.status, errorIdentifier, details }); + } + } + + if (isJson) { + return (await response.json()) as T; + } + + return (await response.text()) as unknown as T; + } catch (error) { + if (timeoutId) { + clearTimeout(timeoutId); + } + + if (error instanceof OpenProjectError) { + throw error; + } + + const isTimeout = + (error as { name?: string })?.name === "TimeoutError" || + (error as { name?: string })?.name === "AbortError"; + + if (isTimeout) { + throw new OpenProjectNetworkError( + `Request to OpenProject timed out after ${this.timeoutMs}ms.`, + { cause: error } + ); + } + + const rawMsg = (error as Error)?.message ?? String(error); + throw new OpenProjectNetworkError( + `Failed to connect to OpenProject: ${this.sanitize(rawMsg)}`, + { cause: error } + ); + } + } +} diff --git a/src/client/filter-builder.ts b/src/client/filter-builder.ts new file mode 100644 index 0000000..3e8f0bb --- /dev/null +++ b/src/client/filter-builder.ts @@ -0,0 +1,157 @@ +/** + * Filter Builder for OpenProject REST API v3 JSON filter syntax. + */ + +import type { + FilterElement, + FilterOperator, + WorkPackageFilterParams, +} from "./types.ts"; + +/** + * Fluent builder for creating OpenProject API v3 filter arrays. + */ +export class FilterBuilder { + private filters: FilterElement[] = []; + + /** + * Adds an arbitrary property filter. + */ + add( + property: string, + operator: FilterOperator, + values: (string | number | boolean)[] = [] + ): this { + this.filters.push({ + [property]: { + operator, + values: values.map(String), + }, + }); + return this; + } + + /** + * Filters by project ID or identifier. + */ + project(projectId: number | string): this { + return this.add("project", "=", [projectId]); + } + + /** + * Filters by work package status. + * Accepts "open", "closed", or specific numeric/string status IDs. + */ + status(status: "open" | "closed" | string | number): this { + if (status === "open") { + return this.add("status", "o", []); + } + if (status === "closed") { + return this.add("status", "c", []); + } + return this.add("status", "=", [status]); + } + + /** + * Filters by work package type ID. + */ + type(typeId: number | string): this { + return this.add("type", "=", [typeId]); + } + + /** + * Filters by assignee user ID or "me". + */ + assignee(assigneeId: number | string): this { + return this.add("assignee", "=", [assigneeId]); + } + + /** + * Filters by author user ID. + */ + author(authorId: number | string): this { + return this.add("author", "=", [authorId]); + } + + /** + * Filters by priority ID. + */ + priority(priorityId: number | string): this { + return this.add("priority", "=", [priorityId]); + } + + /** + * Filters by subject (substring match). + */ + subject(text: string): this { + return this.add("subject", "~", [text]); + } + + /** + * Returns the constructed filter array. + */ + build(): FilterElement[] { + return [...this.filters]; + } + + /** + * Serializes the filter array into JSON string expected by OpenProject API v3. + */ + toJSON(): string { + return JSON.stringify(this.filters); + } +} + +/** + * Builds an OpenProject API v3 filter array from a WorkPackageFilterParams object. + */ +export function buildWorkPackageFilters( + params: WorkPackageFilterParams +): FilterElement[] { + const builder = new FilterBuilder(); + + if (params.projectId !== undefined && params.projectId !== "") { + builder.project(params.projectId); + } + + if (params.status !== undefined && params.status !== "") { + builder.status(params.status); + } + + if (params.typeId !== undefined && params.typeId !== "") { + builder.type(params.typeId); + } + + if (params.assigneeId !== undefined && params.assigneeId !== "") { + builder.assignee(params.assigneeId); + } + + if (params.authorId !== undefined && params.authorId !== "") { + builder.author(params.authorId); + } + + if (params.priorityId !== undefined && params.priorityId !== "") { + builder.priority(params.priorityId); + } + + if (params.subject !== undefined && params.subject.trim() !== "") { + builder.subject(params.subject.trim()); + } + + if (params.customFilters && params.customFilters.length > 0) { + for (const filter of params.customFilters) { + for (const [prop, criteria] of Object.entries(filter)) { + builder.add(prop, criteria.operator, criteria.values); + } + } + } + + return builder.build(); +} + +/** + * Serializes a filter array to a JSON string for use in query parameters. + */ +export function serializeFilters(filters: FilterElement[]): string { + return JSON.stringify(filters); +} diff --git a/src/client/hal-parser.ts b/src/client/hal-parser.ts new file mode 100644 index 0000000..9ca3d54 --- /dev/null +++ b/src/client/hal-parser.ts @@ -0,0 +1,385 @@ +/** + * HAL+JSON Normalization and Parsing Utilities. + * Unpacks nested HAL links and embedded data into token-efficient JSON models. + */ + +import type { + HalCollection, + HalLink, + HalResource, + PaginatedResult, + PriorityItem, + ProjectDetail, + ProjectSummary, + QueryDetail, + QuerySummary, + StatusItem, + TypeItem, + UserItem, + WorkPackageDetail, + WorkPackageSummary, +} from "./types.ts"; + +/** + * Extracts a numeric entity ID from an OpenProject HAL href URL. + * e.g. "/api/v3/projects/4" -> 4 + */ +export function extractIdFromHref(href?: string | null): number | undefined { + if (!href) return undefined; + const match = href.match(/\/(\d+)(?:[/?#]|$)/); + if (match && match[1]) { + return parseInt(match[1], 10); + } + return undefined; +} + +/** + * Resolves a HAL link into its extracted ID, title, and href. + */ +export function resolveLink( + link?: HalLink | null +): { id?: number; title?: string; href?: string } | undefined { + if (!link || !link.href) return undefined; + return { + id: extractIdFromHref(link.href), + title: link.title, + href: link.href, + }; +} + +/** + * Extracts raw plain text from an OpenProject formatted text field. + */ +export function extractRawText(field: unknown): string { + if (!field) return ""; + if (typeof field === "string") return field; + if (typeof field === "object" && field !== null) { + const obj = field as Record; + if (typeof obj.raw === "string") return obj.raw; + if (typeof obj.html === "string") return obj.html; + } + return String(field); +} + +/** + * Unpacks a HAL Collection into a standard PaginatedResult using a transformer function. + */ +export function unpackCollection( + collection: HalCollection, + itemTransformer: (item: TItem) => TResult +): PaginatedResult { + const rawElements = collection._embedded?.elements ?? []; + return { + items: rawElements.map(itemTransformer), + total: collection.total ?? rawElements.length, + count: collection.count ?? rawElements.length, + pageSize: collection.pageSize ?? rawElements.length, + offset: collection.offset ?? 1, + }; +} + +/** + * Normalizes a project HAL resource into a token-efficient ProjectSummary. + */ +export function normalizeProjectSummary(resource: HalResource): ProjectSummary { + const selfHref = (resource._links?.self as HalLink | undefined)?.href; + return { + id: resource.id ?? extractIdFromHref(selfHref) ?? 0, + identifier: String(resource.identifier ?? ""), + name: String(resource.name ?? ""), + active: Boolean(resource.active), + public: Boolean(resource.public), + description: extractRawText(resource.description), + createdAt: typeof resource.createdAt === "string" ? resource.createdAt : undefined, + updatedAt: typeof resource.updatedAt === "string" ? resource.updatedAt : undefined, + }; +} + +/** + * Normalizes a project HAL resource into a complete ProjectDetail. + */ +export function normalizeProject(resource: HalResource): ProjectDetail { + const summary = normalizeProjectSummary(resource); + const parentLink = resource._links?.parent as HalLink | undefined; + const statusLink = resource._links?.status as HalLink | undefined; + + return { + ...summary, + parentId: extractIdFromHref(parentLink?.href) ?? null, + parentName: parentLink?.title ?? null, + status: statusLink?.title ?? (typeof resource.status === "string" ? resource.status : null), + statusExplanation: extractRawText(resource.statusExplanation) || null, + }; +} + +/** + * Normalizes a work package HAL resource into a token-efficient WorkPackageSummary. + */ +export function normalizeWorkPackageSummary(resource: HalResource): WorkPackageSummary { + const links = resource._links ?? {}; + const selfHref = (links.self as HalLink | undefined)?.href; + const typeLink = links.type as HalLink | undefined; + const statusLink = links.status as HalLink | undefined; + const priorityLink = links.priority as HalLink | undefined; + const projectLink = links.project as HalLink | undefined; + const authorLink = links.author as HalLink | undefined; + const assigneeLink = links.assignee as HalLink | undefined; + + return { + id: resource.id ?? extractIdFromHref(selfHref) ?? 0, + subject: String(resource.subject ?? ""), + type: typeLink?.title ?? "", + typeId: extractIdFromHref(typeLink?.href), + status: statusLink?.title ?? "", + statusId: extractIdFromHref(statusLink?.href), + priority: priorityLink?.title, + priorityId: extractIdFromHref(priorityLink?.href), + project: projectLink?.title ?? "", + projectId: extractIdFromHref(projectLink?.href), + author: authorLink?.title, + authorId: extractIdFromHref(authorLink?.href), + assignee: assigneeLink?.title, + assigneeId: extractIdFromHref(assigneeLink?.href), + startDate: typeof resource.startDate === "string" ? resource.startDate : null, + dueDate: typeof resource.dueDate === "string" ? resource.dueDate : null, + estimatedTime: typeof resource.estimatedTime === "string" ? resource.estimatedTime : null, + spentTime: typeof resource.spentTime === "string" ? resource.spentTime : null, + percentageDone: typeof resource.percentageDone === "number" ? resource.percentageDone : null, + createdAt: typeof resource.createdAt === "string" ? resource.createdAt : undefined, + updatedAt: typeof resource.updatedAt === "string" ? resource.updatedAt : undefined, + }; +} + +/** + * Normalizes a work package HAL resource into a complete WorkPackageDetail. + */ +export function normalizeWorkPackage(resource: HalResource): WorkPackageDetail { + const summary = normalizeWorkPackageSummary(resource); + const links = resource._links ?? {}; + const parentLink = links.parent as HalLink | undefined; + + let parent: { id: number; subject?: string } | null = null; + if (parentLink?.href) { + const parentId = extractIdFromHref(parentLink.href); + if (parentId !== undefined) { + parent = { + id: parentId, + subject: parentLink.title, + }; + } + } + + const children: Array<{ id: number; subject?: string }> = []; + const rawChildren = links.children; + if (Array.isArray(rawChildren)) { + for (const child of rawChildren) { + const childLink = child as HalLink; + const childId = extractIdFromHref(childLink.href); + if (childId !== undefined) { + children.push({ + id: childId, + subject: childLink.title, + }); + } + } + } + + // Extract custom fields if present (customFieldX keys) + const customFields: Record = {}; + for (const [key, val] of Object.entries(resource)) { + if (key.startsWith("customField")) { + customFields[key] = val; + } + } + + return { + ...summary, + description: extractRawText(resource.description), + parent, + children: children.length > 0 ? children : undefined, + lockVersion: typeof resource.lockVersion === "number" ? resource.lockVersion : 0, + customFields: Object.keys(customFields).length > 0 ? customFields : undefined, + }; +} + +/** + * Normalizes a query HAL resource into a QuerySummary. + */ +export function normalizeQuerySummary(resource: HalResource): QuerySummary { + const selfHref = (resource._links?.self as HalLink | undefined)?.href; + const projectLink = resource._links?.project as HalLink | undefined; + + return { + id: resource.id ?? extractIdFromHref(selfHref) ?? 0, + name: String(resource.name ?? ""), + public: Boolean(resource.public), + starred: typeof resource.starred === "boolean" ? resource.starred : undefined, + projectId: extractIdFromHref(projectLink?.href) ?? null, + projectName: projectLink?.title ?? null, + }; +} + +/** + * Normalizes a query HAL resource into a complete QueryDetail. + */ +export function normalizeQuery(resource: HalResource): QueryDetail { + const summary = normalizeQuerySummary(resource); + const links = resource._links ?? {}; + + // Extract columns + const columns: string[] = []; + const rawColumns = links.columns; + if (Array.isArray(rawColumns)) { + for (const col of rawColumns) { + const link = col as HalLink; + columns.push(link.title ?? link.href?.split("/").pop() ?? ""); + } + } + + // Extract sortBy + const sortBy: Array<{ attribute: string; direction: "asc" | "desc" }> = []; + const rawSortBy = links.sortBy; + if (Array.isArray(rawSortBy)) { + for (const item of rawSortBy) { + const link = item as HalLink; + const title = link.title ?? ""; + const isDesc = title.toLowerCase().includes("descending") || link.href?.endsWith("-desc"); + const attr = link.href?.split("/").pop()?.replace(/-asc|-desc/, "") ?? title; + sortBy.push({ attribute: attr, direction: isDesc ? "desc" : "asc" }); + } + } + + // Extract filters + const filters: Array<{ field: string; operator: string; values: string[] }> = []; + if (Array.isArray(resource.filters)) { + for (const f of resource.filters as Array>) { + const fLinks = (f._links ?? {}) as Record; + const opLink = fLinks.operator as HalLink | undefined; + const filterLink = fLinks.filter as HalLink | undefined; + const field = filterLink?.title ?? String(f.name ?? ""); + const operator = opLink?.title ?? opLink?.href?.split("/").pop() ?? ""; + const values: string[] = []; + if (Array.isArray(f.values)) { + values.push(...f.values.map(String)); + } + filters.push({ field, operator, values }); + } + } + + const resultsLink = links.results as HalLink | undefined; + + return { + ...summary, + columns, + sortBy: sortBy.length > 0 ? sortBy : undefined, + filters, + resultsHref: resultsLink?.href ?? undefined, + }; +} + +/** + * Normalizes a Status HAL resource. + */ +export function normalizeStatus(resource: HalResource): StatusItem { + return { + id: resource.id ?? extractIdFromHref((resource._links?.self as HalLink | undefined)?.href) ?? 0, + name: String(resource.name ?? ""), + isClosed: Boolean(resource.isClosed), + isDefault: typeof resource.isDefault === "boolean" ? resource.isDefault : undefined, + color: typeof resource.color === "string" ? resource.color : undefined, + }; +} + +/** + * Normalizes a Type HAL resource. + */ +export function normalizeType(resource: HalResource): TypeItem { + return { + id: resource.id ?? extractIdFromHref((resource._links?.self as HalLink | undefined)?.href) ?? 0, + name: String(resource.name ?? ""), + isMilestone: Boolean(resource.isMilestone), + isDefault: typeof resource.isDefault === "boolean" ? resource.isDefault : undefined, + color: typeof resource.color === "string" ? resource.color : undefined, + }; +} + +/** + * Normalizes a Priority HAL resource. + */ +export function normalizePriority(resource: HalResource): PriorityItem { + return { + id: resource.id ?? extractIdFromHref((resource._links?.self as HalLink | undefined)?.href) ?? 0, + name: String(resource.name ?? ""), + isActive: typeof resource.isActive === "boolean" ? resource.isActive : true, + isDefault: typeof resource.isDefault === "boolean" ? resource.isDefault : undefined, + color: typeof resource.color === "string" ? resource.color : undefined, + }; +} + +/** + * Normalizes a User HAL resource. + */ +export function normalizeUser(resource: HalResource): UserItem { + return { + id: resource.id ?? extractIdFromHref((resource._links?.self as HalLink | undefined)?.href) ?? 0, + name: String(resource.name ?? ""), + login: typeof resource.login === "string" ? resource.login : undefined, + email: typeof resource.email === "string" ? resource.email : undefined, + admin: typeof resource.admin === "boolean" ? resource.admin : undefined, + status: typeof resource.status === "string" ? resource.status : undefined, + }; +} + +/** + * Low-level cleaner that strips operational action links and flattens + * generic HAL resources for general token-efficient LLM consumption. + */ +export function unpackHalResource(resource: HalResource): Record { + const result: Record = {}; + + const operationalLinks = new Set([ + "self", "schema", "update", "updateImmediately", "delete", "createWorkPackage", + "editWorkPackage", "createWorkPackageImmediate", "addAttachment", "addWatcher", + "removeWatcher", "addRelation", "addChild", "changeParent", "addComment", + "previewMarkup", "logTime", "move", "copy", "pdf", "generate_pdf", "atom", + "configureForm", "star", "unstar", "watch", "unwatch", "favor", "unfavor", + ]); + + for (const [key, value] of Object.entries(resource)) { + if (key === "_links") { + const links = value as Record; + const cleanedLinks: Record = {}; + + for (const [linkRel, linkVal] of Object.entries(links)) { + if (operationalLinks.has(linkRel) || !linkVal) { + continue; + } + + if (Array.isArray(linkVal)) { + cleanedLinks[linkRel] = linkVal.map((l) => ({ + id: extractIdFromHref(l.href), + title: l.title ?? undefined, + })); + } else if (linkVal.href) { + cleanedLinks[linkRel] = { + id: extractIdFromHref(linkVal.href), + title: linkVal.title ?? undefined, + }; + } + } + + if (Object.keys(cleanedLinks).length > 0) { + result.links = cleanedLinks; + } + } else if (key === "_embedded") { + const embedded = value as Record; + result.embedded = embedded; + } else if (key === "description" || key === "statusExplanation") { + result[key] = extractRawText(value); + } else { + result[key] = value; + } + } + + return result; +} diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..4fb57ac --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,4 @@ +export * from "./types.ts"; +export * from "./api-client.ts"; +export * from "./hal-parser.ts"; +export * from "./filter-builder.ts"; diff --git a/src/client/types.ts b/src/client/types.ts new file mode 100644 index 0000000..f48945e --- /dev/null +++ b/src/client/types.ts @@ -0,0 +1,195 @@ +/** + * OpenProject REST API v3 Types and Domain Interfaces. + */ + +// ============================================================================= +// HAL+JSON Primitive Interfaces +// ============================================================================= + +export interface HalLink { + href: string | null; + title?: string; + templated?: boolean; + method?: string; + type?: string; + payload?: Record; + [key: string]: unknown; +} + +export type HalLinks = Record; + +export interface HalResource { + _type: string; + id?: number; + _links?: HalLinks; + _embedded?: Record; + [key: string]: unknown; +} + +export interface HalCollection extends HalResource { + _type: "Collection" | "WorkPackageCollection" | string; + total: number; + count: number; + pageSize: number; + offset: number; + _embedded: { + elements: T[]; + [key: string]: unknown; + }; +} + +export interface OpenProjectApiErrorPayload { + _type?: string; + errorIdentifier?: string; + message?: string; + errors?: Record | unknown[]; +} + +// ============================================================================= +// Normalized Domain Models (Token-Efficient LLM Representations) +// ============================================================================= + +export interface PaginatedResult { + items: T[]; + total: number; + count: number; + pageSize: number; + offset: number; +} + +export interface ProjectSummary { + id: number; + identifier: string; + name: string; + active: boolean; + public: boolean; + description?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface ProjectDetail extends ProjectSummary { + parentId?: number | null; + parentName?: string | null; + status?: string | null; + statusExplanation?: string | null; +} + +export interface WorkPackageSummary { + id: number; + subject: string; + type: string; + typeId?: number; + status: string; + statusId?: number; + priority?: string; + priorityId?: number; + project: string; + projectId?: number; + author?: string; + authorId?: number; + assignee?: string; + assigneeId?: number; + startDate?: string | null; + dueDate?: string | null; + estimatedTime?: string | null; + spentTime?: string | null; + percentageDone?: number | null; + createdAt?: string; + updatedAt?: string; +} + +export interface WorkPackageDetail extends WorkPackageSummary { + description?: string; + parent?: { id: number; subject?: string } | null; + children?: Array<{ id: number; subject?: string }>; + lockVersion: number; + customFields?: Record; +} + +export interface QuerySummary { + id: number; + name: string; + public: boolean; + starred?: boolean; + projectId?: number | null; + projectName?: string | null; +} + +export interface QueryDetail extends QuerySummary { + columns: string[]; + sortBy?: Array<{ attribute: string; direction: "asc" | "desc" }>; + filters: Array<{ field: string; operator: string; values: string[] }>; + resultsHref?: string; +} + +export interface StatusItem { + id: number; + name: string; + isClosed: boolean; + isDefault?: boolean; + color?: string; +} + +export interface TypeItem { + id: number; + name: string; + isMilestone: boolean; + isDefault?: boolean; + color?: string; +} + +export interface PriorityItem { + id: number; + name: string; + isActive: boolean; + isDefault?: boolean; + color?: string; +} + +export interface UserItem { + id: number; + name: string; + login?: string; + email?: string; + admin?: boolean; + status?: string; +} + +// ============================================================================= +// Filter Criteria Interfaces +// ============================================================================= + +export type FilterOperator = + | "=" + | "!" + | "!=" + | "~" + | "!~" + | ">=" + | "<=" + | "o" // open + | "c" // closed + | "*" // any / all + | "t" // today + | "w" // this week + | "nd" // next days + | string; + +export interface FilterElement { + [property: string]: { + operator: FilterOperator; + values: string[]; + }; +} + +export interface WorkPackageFilterParams { + projectId?: number | string; + status?: "open" | "closed" | string | number; + typeId?: number | string; + assigneeId?: number | string; + authorId?: number | string; + priorityId?: number | string; + subject?: string; + customFilters?: FilterElement[]; +} diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..0c1125d --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; + +/** + * Zod schema for OpenProject MCP server configuration. + */ +export const configSchema = z.object({ + baseUrl: z + .string({ required_error: "OPENPROJECT_BASE_URL is required" }) + .url("OPENPROJECT_BASE_URL must be a valid URL") + .transform((val) => val.replace(/\/+$/, "")), + apiKey: z + .string({ required_error: "OPENPROJECT_API_KEY is required" }) + .min(1, "OPENPROJECT_API_KEY must not be empty"), + readOnly: z.boolean().default(false), +}); + +export type AppConfig = z.infer; + +/** + * Parses and validates configuration from environment variables and CLI arguments. + * + * @param env - Environment variable map (defaults to process.env) + * @param argv - CLI arguments array (defaults to process.argv) + * @returns Validated AppConfig object + */ +export function parseConfig( + env: Record = process.env, + argv: string[] = process.argv +): AppConfig { + const isReadOnlyArg = argv.includes("--read-only"); + const readOnlyEnv = env.OPENPROJECT_READ_ONLY?.trim().toLowerCase(); + const isReadOnly = + isReadOnlyArg || + readOnlyEnv === "true" || + readOnlyEnv === "1" || + readOnlyEnv === "yes"; + + return configSchema.parse({ + baseUrl: env.OPENPROJECT_BASE_URL, + apiKey: env.OPENPROJECT_API_KEY, + readOnly: isReadOnly, + }); +} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..064d1aa --- /dev/null +++ b/src/context.ts @@ -0,0 +1,50 @@ + +/** + * Request Context and AsyncLocalStorage Scoping for Multi-User Safety. + */ + +import { AsyncLocalStorage } from "node:async_hooks"; +import type { OpenProjectClient } from "./client/api-client.ts"; + +export interface RequestContext { + client: OpenProjectClient; + isReadOnly: boolean; + sessionId?: string; + userId?: string; +} + +const asyncLocalStorage = new AsyncLocalStorage(); + +/** + * Retrieves the current request context for the active asynchronous execution chain. + * Throws an error if called outside of a runWithContext() scope. + */ +export function getRequestContext(): RequestContext { + const context = asyncLocalStorage.getStore(); + if (!context) { + throw new Error( + "No active RequestContext found. Operations must be executed within runWithContext()." + ); + } + return context; +} + +/** + * Attempts to retrieve the current request context, returning undefined if outside scope. + */ +export function tryGetRequestContext(): RequestContext | undefined { + return asyncLocalStorage.getStore(); +} + +/** + * Executes a function within the scope of a specific RequestContext. + * + * @param context - Request context holding the isolated OpenProject client and options + * @param fn - Asynchronous or synchronous callback to execute + */ +export function runWithContext( + context: RequestContext, + fn: () => T +): T { + return asyncLocalStorage.run(context, fn); +} diff --git a/tests/client.test.ts b/tests/client.test.ts new file mode 100644 index 0000000..7a78e6d --- /dev/null +++ b/tests/client.test.ts @@ -0,0 +1,608 @@ +import { describe, expect, test } from "bun:test"; +import { parseConfig } from "../src/config/index.ts"; +import { + getRequestContext, + runWithContext, + tryGetRequestContext, + type RequestContext, +} from "../src/context.ts"; +import { + OpenProjectClient, + OpenProjectAuthenticationError, + OpenProjectForbiddenError, + OpenProjectNotFoundError, + OpenProjectConflictError, + OpenProjectValidationError, + OpenProjectRateLimitError, + OpenProjectServerError, +} from "../src/client/api-client.ts"; +import { + FilterBuilder, + buildWorkPackageFilters, + serializeFilters, +} from "../src/client/filter-builder.ts"; +import { + extractIdFromHref, + resolveLink, + extractRawText, + normalizeProjectSummary, + normalizeProject, + normalizeWorkPackageSummary, + normalizeWorkPackage, + normalizeQuerySummary, + normalizeQuery, + normalizeStatus, + normalizeType, + normalizePriority, + normalizeUser, + unpackCollection, + unpackHalResource, +} from "../src/client/hal-parser.ts"; +import type { HalCollection, HalResource } from "../src/client/types.ts"; + +// ============================================================================= +// 1. Configuration Parsing & Validation +// ============================================================================= + +describe("Configuration Parser", () => { + test("parses valid configuration and trims trailing slashes", () => { + const config = parseConfig( + { + OPENPROJECT_BASE_URL: "http://localhost:8080///", + OPENPROJECT_API_KEY: "valid-test-key-12345", + }, + [] + ); + + expect(config.baseUrl).toBe("http://localhost:8080"); + expect(config.apiKey).toBe("valid-test-key-12345"); + expect(config.readOnly).toBe(false); + }); + + test("enables readOnly mode from env OPENPROJECT_READ_ONLY", () => { + const config = parseConfig( + { + OPENPROJECT_BASE_URL: "http://localhost:8080", + OPENPROJECT_API_KEY: "secret", + OPENPROJECT_READ_ONLY: "true", + }, + [] + ); + expect(config.readOnly).toBe(true); + }); + + test("enables readOnly mode from --read-only argv flag", () => { + const config = parseConfig( + { + OPENPROJECT_BASE_URL: "http://localhost:8080", + OPENPROJECT_API_KEY: "secret", + }, + ["node", "index.js", "--read-only"] + ); + expect(config.readOnly).toBe(true); + }); + + test("throws error when OPENPROJECT_BASE_URL is invalid", () => { + expect(() => + parseConfig({ + OPENPROJECT_BASE_URL: "not-a-valid-url", + OPENPROJECT_API_KEY: "secret", + }) + ).toThrow(); + }); + + test("throws error when OPENPROJECT_API_KEY is missing or empty", () => { + expect(() => + parseConfig({ + OPENPROJECT_BASE_URL: "http://localhost:8080", + OPENPROJECT_API_KEY: "", + }) + ).toThrow(); + }); +}); + +// ============================================================================= +// 2. Request Scoping & AsyncLocalStorage +// ============================================================================= + +describe("RequestContext Scoping", () => { + test("throws when getRequestContext() is called outside runWithContext", () => { + expect(() => getRequestContext()).toThrow(/No active RequestContext found/); + expect(tryGetRequestContext()).toBeUndefined(); + }); + + test("provides isolated context inside runWithContext", async () => { + const clientA = new OpenProjectClient({ + baseUrl: "http://host-a", + apiKey: "key-a", + }); + const contextA: RequestContext = { + client: clientA, + isReadOnly: false, + userId: "user-a", + }; + + await runWithContext(contextA, async () => { + const activeCtx = getRequestContext(); + expect(activeCtx.userId).toBe("user-a"); + expect(activeCtx.client.baseUrl).toBe("http://host-a"); + expect(activeCtx.isReadOnly).toBe(false); + }); + }); + + test("maintains isolated context across concurrent asynchronous tasks", async () => { + const client1 = new OpenProjectClient({ + baseUrl: "http://localhost:8080", + apiKey: "key-user-1", + }); + const client2 = new OpenProjectClient({ + baseUrl: "http://localhost:8080", + apiKey: "key-user-2", + }); + + const ctx1: RequestContext = { client: client1, isReadOnly: false, userId: "user-1" }; + const ctx2: RequestContext = { client: client2, isReadOnly: true, userId: "user-2" }; + + const task1 = runWithContext(ctx1, async () => { + await new Promise((resolve) => setTimeout(resolve, 15)); + const current = getRequestContext(); + expect(current.userId).toBe("user-1"); + expect(current.isReadOnly).toBe(false); + return current.userId; + }); + + const task2 = runWithContext(ctx2, async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + const current = getRequestContext(); + expect(current.userId).toBe("user-2"); + expect(current.isReadOnly).toBe(true); + return current.userId; + }); + + const results = await Promise.all([task1, task2]); + expect(results).toEqual(["user-1", "user-2"]); + }); +}); + +// ============================================================================= +// 3. Filter Builder +// ============================================================================= + +describe("FilterBuilder", () => { + test("builds individual filters correctly", () => { + const builder = new FilterBuilder(); + builder.project(12).status("open").type(3); + + const filters = builder.build(); + expect(filters).toEqual([ + { project: { operator: "=", values: ["12"] } }, + { status: { operator: "o", values: [] } }, + { type: { operator: "=", values: ["3"] } }, + ]); + }); + + test("handles closed and specific status IDs", () => { + const builderClosed = new FilterBuilder().status("closed"); + expect(builderClosed.build()).toEqual([ + { status: { operator: "c", values: [] } }, + ]); + + const builderSpecific = new FilterBuilder().status("7"); + expect(builderSpecific.build()).toEqual([ + { status: { operator: "=", values: ["7"] } }, + ]); + }); + + test("builds full query from WorkPackageFilterParams", () => { + const filters = buildWorkPackageFilters({ + projectId: "mcp-test-project", + status: "open", + assigneeId: "me", + priorityId: 2, + subject: "bug in auth", + }); + + expect(filters).toEqual([ + { project: { operator: "=", values: ["mcp-test-project"] } }, + { status: { operator: "o", values: [] } }, + { assignee: { operator: "=", values: ["me"] } }, + { priority: { operator: "=", values: ["2"] } }, + { subject: { operator: "~", values: ["bug in auth"] } }, + ]); + + const serialized = serializeFilters(filters); + expect(typeof serialized).toBe("string"); + expect(JSON.parse(serialized)).toEqual(filters); + }); +}); + +// ============================================================================= +// 4. HAL Parser & Normalizers +// ============================================================================= + +describe("HAL Normalizer", () => { + test("extracts numeric IDs from URLs", () => { + expect(extractIdFromHref("/api/v3/projects/42")).toBe(42); + expect(extractIdFromHref("/api/v3/work_packages/123/relations")).toBe(123); + expect(extractIdFromHref(null)).toBeUndefined(); + expect(extractIdFromHref("")).toBeUndefined(); + }); + + test("resolves links and text fields", () => { + const link = { href: "/api/v3/types/5", title: "Bug" }; + expect(resolveLink(link)).toEqual({ id: 5, title: "Bug", href: "/api/v3/types/5" }); + expect(resolveLink(null)).toBeUndefined(); + + expect(extractRawText("plain string")).toBe("plain string"); + expect(extractRawText({ raw: "raw markdown", html: "

html

" })).toBe("raw markdown"); + expect(extractRawText({ html: "

html only

" })).toBe("

html only

"); + expect(extractRawText(null)).toBe(""); + }); + + test("normalizes Project HAL resource", () => { + const rawProject: HalResource = { + _type: "Project", + id: 4, + identifier: "test-proj", + name: "Test Project", + active: true, + public: false, + description: { raw: "Project description", html: "

Project description

" }, + statusExplanation: { raw: "All green", html: "" }, + _links: { + self: { href: "/api/v3/projects/4", title: "Test Project" }, + parent: { href: "/api/v3/projects/1", title: "Parent Project" }, + status: { href: "/api/v3/project_statuses/2", title: "ON TRACK" }, + }, + }; + + const detail = normalizeProject(rawProject); + expect(detail.id).toBe(4); + expect(detail.identifier).toBe("test-proj"); + expect(detail.name).toBe("Test Project"); + expect(detail.active).toBe(true); + expect(detail.public).toBe(false); + expect(detail.description).toBe("Project description"); + expect(detail.parentId).toBe(1); + expect(detail.parentName).toBe("Parent Project"); + expect(detail.status).toBe("ON TRACK"); + expect(detail.statusExplanation).toBe("All green"); + }); + + test("normalizes WorkPackage HAL resource", () => { + const rawWp: HalResource = { + _type: "WorkPackage", + id: 55, + subject: "Fix critical login loop", + startDate: "2026-09-01", + dueDate: "2026-09-10", + lockVersion: 3, + description: { raw: "Investigation needed." }, + _links: { + self: { href: "/api/v3/work_packages/55" }, + type: { href: "/api/v3/types/2", title: "Bug" }, + status: { href: "/api/v3/statuses/1", title: "New" }, + priority: { href: "/api/v3/priorities/8", title: "High" }, + project: { href: "/api/v3/projects/4", title: "Test Project" }, + author: { href: "/api/v3/users/1", title: "Admin User" }, + assignee: { href: "/api/v3/users/2", title: "Developer" }, + parent: { href: "/api/v3/work_packages/50", title: "Parent Task" }, + children: [ + { href: "/api/v3/work_packages/56", title: "Subtask 1" }, + { href: "/api/v3/work_packages/57", title: "Subtask 2" }, + ], + }, + }; + + const detail = normalizeWorkPackage(rawWp); + expect(detail.id).toBe(55); + expect(detail.subject).toBe("Fix critical login loop"); + expect(detail.type).toBe("Bug"); + expect(detail.typeId).toBe(2); + expect(detail.status).toBe("New"); + expect(detail.statusId).toBe(1); + expect(detail.priority).toBe("High"); + expect(detail.priorityId).toBe(8); + expect(detail.project).toBe("Test Project"); + expect(detail.projectId).toBe(4); + expect(detail.author).toBe("Admin User"); + expect(detail.authorId).toBe(1); + expect(detail.assignee).toBe("Developer"); + expect(detail.assigneeId).toBe(2); + expect(detail.parent).toEqual({ id: 50, subject: "Parent Task" }); + expect(detail.children).toEqual([ + { id: 56, subject: "Subtask 1" }, + { id: 57, subject: "Subtask 2" }, + ]); + expect(detail.description).toBe("Investigation needed."); + expect(detail.lockVersion).toBe(3); + }); + + test("normalizes Query, Status, Type, Priority, and User", () => { + const rawQuery: HalResource = { + _type: "Query", + id: 10, + name: "My Tasks", + public: true, + starred: true, + filters: [ + { + _links: { + filter: { title: "Status" }, + operator: { title: "open" }, + }, + values: [], + }, + ], + _links: { + self: { href: "/api/v3/queries/10" }, + project: { href: "/api/v3/projects/4", title: "Test Project" }, + columns: [ + { href: "/api/v3/queries/columns/id", title: "ID" }, + { href: "/api/v3/queries/columns/subject", title: "Subject" }, + ], + sortBy: [{ href: "/api/v3/queries/sort_bys/id-desc", title: "ID (descending)" }], + }, + }; + + const query = normalizeQuery(rawQuery); + expect(query.id).toBe(10); + expect(query.name).toBe("My Tasks"); + expect(query.columns).toEqual(["ID", "Subject"]); + expect(query.sortBy).toEqual([{ attribute: "id", direction: "desc" }]); + expect(query.filters).toEqual([{ field: "Status", operator: "open", values: [] }]); + + const status = normalizeStatus({ _type: "Status", id: 1, name: "New", isClosed: false }); + expect(status).toEqual({ id: 1, name: "New", isClosed: false, isDefault: undefined, color: undefined }); + + const type = normalizeType({ _type: "Type", id: 2, name: "Bug", isMilestone: false }); + expect(type).toEqual({ id: 2, name: "Bug", isMilestone: false, isDefault: undefined, color: undefined }); + + const priority = normalizePriority({ _type: "Priority", id: 8, name: "High", isActive: true }); + expect(priority).toEqual({ id: 8, name: "High", isActive: true, isDefault: undefined, color: undefined }); + + const user = normalizeUser({ _type: "User", id: 4, name: "Admin", login: "admin", email: "a@b.com", admin: true }); + expect(user).toEqual({ id: 4, name: "Admin", login: "admin", email: "a@b.com", admin: true, status: undefined }); + }); + + test("unpacks collection with pagination metadata", () => { + const rawCollection: HalCollection = { + _type: "Collection", + total: 100, + count: 2, + pageSize: 2, + offset: 1, + _embedded: { + elements: [ + { _type: "Status", id: 1, name: "New", isClosed: false }, + { _type: "Status", id: 2, name: "Closed", isClosed: true }, + ], + }, + }; + + const unpacked = unpackCollection(rawCollection, normalizeStatus); + expect(unpacked.total).toBe(100); + expect(unpacked.count).toBe(2); + expect(unpacked.pageSize).toBe(2); + expect(unpacked.offset).toBe(1); + expect(unpacked.items.length).toBe(2); + expect(unpacked.items[0]?.name).toBe("New"); + expect(unpacked.items[1]?.name).toBe("Closed"); + }); + + test("unpackHalResource strips noisy operational links", () => { + const raw: HalResource = { + _type: "WorkPackage", + id: 1, + subject: "Test", + _links: { + self: { href: "/api/v3/work_packages/1" }, + update: { href: "/api/v3/work_packages/1/form", method: "post" }, + delete: { href: "/api/v3/work_packages/1", method: "delete" }, + schema: { href: "/api/v3/work_packages/schemas/1" }, + project: { href: "/api/v3/projects/4", title: "Project 4" }, + }, + }; + + const stripped = unpackHalResource(raw); + expect(stripped._type).toBe("WorkPackage"); + expect(stripped.id).toBe(1); + expect(stripped.subject).toBe("Test"); + expect(stripped.links).toEqual({ + project: { id: 4, title: "Project 4" }, + }); + }); +}); + +// ============================================================================= +// 5. OpenProjectClient HTTP & Error Sanitization (Unit Tests with Mock Fetch) +// ============================================================================= + +describe("OpenProjectClient (Mocked Unit Tests)", () => { + test("injects Basic Auth header and JSON Accept headers", async () => { + let capturedHeaders: Headers | undefined; + + const mockFetch = async (_url: string | URL | Request, init?: RequestInit): Promise => { + capturedHeaders = new Headers(init?.headers); + return new Response(JSON.stringify({ _type: "Project", id: 1, name: "P1" }), { + status: 200, + headers: { "Content-Type": "application/hal+json" }, + }); + }; + + const client = new OpenProjectClient({ + baseUrl: "http://example.com", + apiKey: "super-secret-token", + fetchFn: mockFetch, + }); + + const result = await client.get<{ id: number }>("/api/v3/projects/1"); + expect(result.id).toBe(1); + expect(capturedHeaders?.get("Authorization")).toBe(`Basic ${btoa("apikey:super-secret-token")}`); + expect(capturedHeaders?.get("Accept")).toContain("application/hal+json"); + }); + + test("sanitizes API credentials from error messages", async () => { + const secretKey = "super-secret-key-xyz-999"; + const mockFetch = async (): Promise => { + return new Response( + JSON.stringify({ + _type: "Error", + errorIdentifier: "urn:openproject-org:api:v3:errors:Unauthenticated", + message: `Authentication failed for key ${secretKey} and header Basic ${btoa("apikey:" + secretKey)}`, + }), + { status: 401, headers: { "Content-Type": "application/json" } } + ); + }; + + const client = new OpenProjectClient({ + baseUrl: "http://example.com", + apiKey: secretKey, + fetchFn: mockFetch, + }); + + try { + await client.get("/api/v3/projects"); + expect(true).toBe(false); // Should not reach + } catch (err) { + expect(err).toBeInstanceOf(OpenProjectAuthenticationError); + const msg = (err as Error).message; + expect(msg).not.toContain(secretKey); + expect(msg).toContain("[REDACTED_API_KEY]"); + expect(msg).toContain("[REDACTED_AUTH]"); + } + }); + + test("maps HTTP status codes to appropriate error classes", async () => { + const createMockClient = (status: number, body: unknown, headers?: Record) => { + const mockFetch = async (): Promise => { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...headers }, + }); + }; + return new OpenProjectClient({ + baseUrl: "http://example.com", + apiKey: "dummy-key", + fetchFn: mockFetch, + }); + }; + + // 403 Forbidden + await expect( + createMockClient(403, { message: "No permission" }).get("/res") + ).rejects.toBeInstanceOf(OpenProjectForbiddenError); + + // 404 Not Found + await expect( + createMockClient(404, { message: "Item not found" }).get("/res") + ).rejects.toBeInstanceOf(OpenProjectNotFoundError); + + // 409 Conflict + await expect( + createMockClient(409, { message: "Lock version mismatch" }).get("/res") + ).rejects.toBeInstanceOf(OpenProjectConflictError); + + // 422 Validation + await expect( + createMockClient(422, { message: "Invalid attribute" }).get("/res") + ).rejects.toBeInstanceOf(OpenProjectValidationError); + + // 429 Rate Limit + try { + await createMockClient(429, { message: "Slow down" }, { "retry-after": "60" }).get("/res"); + expect(true).toBe(false); + } catch (e) { + expect(e).toBeInstanceOf(OpenProjectRateLimitError); + expect((e as OpenProjectRateLimitError).retryAfter).toBe(60); + } + + // 500 Server Error + await expect( + createMockClient(500, { message: "Server crashed" }).get("/res") + ).rejects.toBeInstanceOf(OpenProjectServerError); + }); +}); + +// ============================================================================= +// 6. Live OpenProject Container Integration Tests +// ============================================================================= + +describe("OpenProject Live API Integration (Local Container)", () => { + const liveBaseUrl = process.env.OPENPROJECT_BASE_URL || "http://localhost:8080"; + const liveApiKey = process.env.OPENPROJECT_API_KEY; + + test("fetches projects and work packages against live instance", async () => { + if (!liveApiKey) { + console.warn("Skipping live container test: OPENPROJECT_API_KEY not set"); + return; + } + + const client = new OpenProjectClient({ + baseUrl: liveBaseUrl, + apiKey: liveApiKey, + }); + + // 1. Fetch projects + const projectsHal = await client.get("/api/v3/projects"); + expect(projectsHal._type).toBe("Collection"); + const unpackedProjects = unpackCollection(projectsHal, normalizeProjectSummary); + expect(unpackedProjects.items.length).toBeGreaterThan(0); + + const testProj = unpackedProjects.items.find((p) => p.identifier === "mcp-test-project"); + expect(testProj).toBeDefined(); + expect(testProj?.name).toBe("MCP Test Project"); + + // 2. Fetch single project detail + const projectDetailHal = await client.get(`/api/v3/projects/${testProj!.id}`); + const projectDetail = normalizeProject(projectDetailHal); + expect(projectDetail.identifier).toBe("mcp-test-project"); + + // 3. Fetch work packages with open status filter + const openWpsHal = await client.get("/api/v3/work_packages", { + filters: serializeFilters(buildWorkPackageFilters({ status: "open" })), + }); + expect(["Collection", "WorkPackageCollection"]).toContain(openWpsHal._type); + const unpackedWps = unpackCollection(openWpsHal, normalizeWorkPackageSummary); + expect(unpackedWps.items.length).toBeGreaterThan(0); + + // 4. Fetch taxonomies (statuses, types, priorities) + const statusesHal = await client.get("/api/v3/statuses"); + const statuses = unpackCollection(statusesHal, normalizeStatus); + expect(statuses.items.length).toBeGreaterThan(0); + expect(statuses.items.some((s) => s.name === "New")).toBe(true); + + const typesHal = await client.get("/api/v3/types"); + const types = unpackCollection(typesHal, normalizeType); + expect(types.items.length).toBeGreaterThan(0); + expect(types.items.some((t) => t.name === "Task")).toBe(true); + + const prioritiesHal = await client.get("/api/v3/priorities"); + const priorities = unpackCollection(prioritiesHal, normalizePriority); + expect(priorities.items.length).toBeGreaterThan(0); + }); + + test("live 401 authentication failure with invalid key", async () => { + const client = new OpenProjectClient({ + baseUrl: liveBaseUrl, + apiKey: "definitely-invalid-key-9999", + }); + + await expect(client.get("/api/v3/projects")).rejects.toBeInstanceOf( + OpenProjectAuthenticationError + ); + }); + + test("live 404 not found failure with non-existent project", async () => { + if (!liveApiKey) return; + + const client = new OpenProjectClient({ + baseUrl: liveBaseUrl, + apiKey: liveApiKey, + }); + + await expect(client.get("/api/v3/projects/9999999")).rejects.toBeInstanceOf( + OpenProjectNotFoundError + ); + }); +});