feat(client): implement OpenProject REST API v3 client layer and context scoping

This commit is contained in:
Lucas Winther
2026-09-05 17:26:43 +02:00
parent 5886419b06
commit f5bff8a81f
9 changed files with 1781 additions and 7 deletions
+332
View File
@@ -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<Response>;
}
// =============================================================================
// 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<Response>;
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<T>(
path: string,
query?: Record<string, string | number | boolean | undefined | null>
): Promise<T> {
const url = this.resolveUrl(path, query);
return this.request<T>(url, { method: "GET" });
}
/**
* Performs an HTTP POST request to OpenProject API v3.
*/
async post<T>(path: string, body?: unknown): Promise<T> {
const url = this.resolveUrl(path);
return this.request<T>(url, {
method: "POST",
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
/**
* Performs an HTTP PATCH request to OpenProject API v3.
*/
async patch<T>(path: string, body?: unknown): Promise<T> {
const url = this.resolveUrl(path);
return this.request<T>(url, {
method: "PATCH",
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
/**
* Performs an HTTP DELETE request to OpenProject API v3.
*/
async delete<T>(path: string): Promise<T> {
const url = this.resolveUrl(path);
return this.request<T>(url, { method: "DELETE" });
}
/**
* Resolves a path or full URL against the client's baseUrl and appends query parameters.
*/
resolveUrl(
path: string,
query?: Record<string, string | number | boolean | undefined | null>
): 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<T>(url: string, init: RequestInit): Promise<T> {
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<typeof setTimeout> | 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 }
);
}
}
}
+157
View File
@@ -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);
}
+385
View File
@@ -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<string, unknown>;
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<TItem = HalResource, TResult = unknown>(
collection: HalCollection<TItem>,
itemTransformer: (item: TItem) => TResult
): PaginatedResult<TResult> {
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<string, unknown> = {};
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<Record<string, unknown>>) {
const fLinks = (f._links ?? {}) as Record<string, unknown>;
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<string, unknown> {
const result: Record<string, unknown> = {};
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<string, HalLink | HalLink[] | undefined>;
const cleanedLinks: Record<string, unknown> = {};
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<string, unknown>;
result.embedded = embedded;
} else if (key === "description" || key === "statusExplanation") {
result[key] = extractRawText(value);
} else {
result[key] = value;
}
}
return result;
}
+4
View File
@@ -0,0 +1,4 @@
export * from "./types.ts";
export * from "./api-client.ts";
export * from "./hal-parser.ts";
export * from "./filter-builder.ts";
+195
View File
@@ -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<string, unknown>;
[key: string]: unknown;
}
export type HalLinks = Record<string, HalLink | HalLink[] | undefined>;
export interface HalResource {
_type: string;
id?: number;
_links?: HalLinks;
_embedded?: Record<string, unknown>;
[key: string]: unknown;
}
export interface HalCollection<T = HalResource> 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<string, unknown> | unknown[];
}
// =============================================================================
// Normalized Domain Models (Token-Efficient LLM Representations)
// =============================================================================
export interface PaginatedResult<T> {
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<string, unknown>;
}
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[];
}
+43
View File
@@ -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<typeof configSchema>;
/**
* 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<string, string | undefined> = 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,
});
}
+50
View File
@@ -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<RequestContext>();
/**
* 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<T>(
context: RequestContext,
fn: () => T
): T {
return asyncLocalStorage.run(context, fn);
}