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
6 changes: 3 additions & 3 deletions packages/adt-server-client/scripts/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,11 @@ export async function renderOpenApiClient(document: unknown): Promise<string> {
.map((operation) => {
const name = typeName(operation.id);
return operation.hasInput
? `${key(operation.id)}: (params: ${name}Params) => request<${name}Response>(operationDefinitions.${operation.id}, params),`
: `${key(operation.id)}: () => request<${name}Response>(operationDefinitions.${operation.id}),`;
? `${key(operation.id)}: (params: ${name}Params, requestOptions?: AdtServerRequestOptions) => request<${name}Response>(operationDefinitions.${operation.id}, params, requestOptions),`
: `${key(operation.id)}: (requestOptions?: AdtServerRequestOptions) => request<${name}Response>(operationDefinitions.${operation.id}, undefined, requestOptions),`;
})
.join('\n');
const source = `/**\n * GENERATED FROM @abapify/adt-server's OpenAPI document.\n * Run \`bun run --filter @abapify/adt-server-client generate\`; do not edit manually.\n */\n\nexport interface AdtServerClientOptions {\n baseUrl: string;\n fetch?: typeof globalThis.fetch;\n headers?: Record<string, string>;\n}\n\nexport class AdtServerHttpError extends Error {\n constructor(\n readonly status: number,\n readonly body: unknown,\n ) {\n super(\`ADT Server request failed (\${status})\`);\n }\n}\n\ntype OperationDefinition = {\n method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\n path: string;\n pathParameters: readonly string[];\n queryParameters: readonly string[];\n hasBody: boolean;\n};\n\nconst operationDefinitions = {\n${definitionSource}\n} as const satisfies Record<string, OperationDefinition>;\n\n${typeSource}\n\nfunction inputValue(input: object | undefined, name: string): unknown {\n return (input as Record<string, unknown> | undefined)?.[name];\n}\n\nexport function createAdtServerClient(options: AdtServerClientOptions) {\n const fetcher = options.fetch ?? globalThis.fetch;\n const request = async <T>(\n definition: OperationDefinition,\n input?: object,\n ): Promise<T> => {\n const path = definition.path.replace(/\\{([A-Za-z_$][A-Za-z0-9_$]*)\\}/gu, (_match, name: string) => {\n const value = inputValue(input, name);\n if (value === undefined || value === null) {\n throw new Error(\`Missing required path parameter: \${name}\`);\n }\n return encodeURIComponent(String(value)); // NOSONAR\n });\n const url = new URL(path, options.baseUrl);\n for (const name of definition.queryParameters) {\n const value = inputValue(input, name);\n if (value !== undefined && value !== null) url.searchParams.set(name, String(value)); // NOSONAR\n }\n const body = inputValue(input, 'body');\n const response = await fetcher(url, {\n method: definition.method,\n headers: {\n accept: 'application/json',\n ...(definition.hasBody ? { 'content-type': 'application/json' } : {}),\n ...options.headers,\n },\n ...(definition.hasBody ? { body: JSON.stringify(body) } : {}),\n });\n const responseBody = await response.json().catch(() => undefined);\n if (!response.ok) {\n throw new AdtServerHttpError(response.status, responseBody);\n }\n return responseBody as T;\n };\n\n return {\n${methodSource}\n };\n}\n\nexport type AdtServerClient = ReturnType<typeof createAdtServerClient>;\n`;
const source = `/**\n * GENERATED FROM @abapify/adt-server's OpenAPI document.\n * Run \`bun run --filter @abapify/adt-server-client generate\`; do not edit manually.\n */\n\nexport interface AdtServerClientOptions {\n baseUrl: string;\n fetch?: typeof globalThis.fetch;\n headers?: Record<string, string>;\n}\n\nexport interface AdtServerRequestOptions {\n signal?: AbortSignal;\n}\n\nexport class AdtServerHttpError extends Error {\n constructor(\n readonly status: number,\n readonly body: unknown,\n ) {\n super(\`ADT Server request failed (\${status})\`);\n }\n}\n\ntype OperationDefinition = {\n method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';\n path: string;\n pathParameters: readonly string[];\n queryParameters: readonly string[];\n hasBody: boolean;\n};\n\nconst operationDefinitions = {\n${definitionSource}\n} as const satisfies Record<string, OperationDefinition>;\n\n${typeSource}\n\nfunction inputValue(input: object | undefined, name: string): unknown {\n return (input as Record<string, unknown> | undefined)?.[name];\n}\n\nexport function createAdtServerClient(options: AdtServerClientOptions) {\n const fetcher = options.fetch ?? globalThis.fetch;\n const request = async <T>(\n definition: OperationDefinition,\n input?: object,\n requestOptions?: AdtServerRequestOptions,\n ): Promise<T> => {\n const path = definition.path.replace(/\\{([A-Za-z_$][A-Za-z0-9_$]*)\\}/gu, (_match, name: string) => {\n const value = inputValue(input, name);\n if (value === undefined || value === null) {\n throw new Error(\`Missing required path parameter: \${name}\`);\n }\n return encodeURIComponent(String(value)); // NOSONAR\n });\n const url = new URL(path, options.baseUrl);\n for (const name of definition.queryParameters) {\n const value = inputValue(input, name);\n if (value !== undefined && value !== null) url.searchParams.set(name, String(value)); // NOSONAR\n }\n const body = inputValue(input, 'body');\n const response = await fetcher(url, {\n method: definition.method,\n headers: {\n accept: 'application/json',\n ...(definition.hasBody ? { 'content-type': 'application/json' } : {}),\n ...options.headers,\n },\n ...(definition.hasBody ? { body: JSON.stringify(body) } : {}),\n signal: requestOptions?.signal,\n });\n const responseBody = await response.json().catch(() => undefined);\n if (!response.ok) {\n throw new AdtServerHttpError(response.status, responseBody);\n }\n return responseBody as T;\n };\n\n return {\n${methodSource}\n };\n}\n\nexport type AdtServerClient = ReturnType<typeof createAdtServerClient>;\n`;
return await format(source, {
...(await resolveConfig(generatedFile)),
filepath: generatedFile,
Expand Down
143 changes: 127 additions & 16 deletions packages/adt-server-client/src/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export interface AdtServerClientOptions {
headers?: Record<string, string>;
}

export interface AdtServerRequestOptions {
signal?: AbortSignal;
}

export class AdtServerHttpError extends Error {
constructor(
readonly status: number,
Expand All @@ -34,6 +38,13 @@ const operationDefinitions = {
queryParameters: [],
hasBody: true,
},
getBadi: {
method: 'GET',
path: '/v1/destinations/{destination}/badi/{name}',
pathParameters: ['destination', 'name'],
queryParameters: ['implementations'],
hasBody: false,
},
getObjectMetadata: {
method: 'GET',
path: '/v1/destinations/{destination}/objects/{type}/{name}',
Expand Down Expand Up @@ -195,6 +206,37 @@ export type BuildTransportSourceManifestResponse = {
diagnostic?: { code: string; message: string };
}>;
};
export type GetBadiParams = {
destination: string;
name: string;
implementations?: boolean;
};
export type GetBadiResponse = {
kind: 'definition' | 'implementation' | 'enhancement';
name: string;
type: string;
description?: string;
language?: string;
version?: string;
packageName?: string;
packageUri?: string;
responsible?: string;
masterLanguage?: string;
masterSystem?: string;
implementations?: Array<{
kind: 'implementation';
name: string;
type: string;
description?: string;
language?: string;
version?: string;
packageName?: string;
packageUri?: string;
responsible?: string;
masterLanguage?: string;
masterSystem?: string;
}>;
};
export type GetObjectMetadataParams = {
destination: string;
type: string;
Expand Down Expand Up @@ -489,6 +531,7 @@ export function createAdtServerClient(options: AdtServerClientOptions) {
const request = async <T>(
definition: OperationDefinition,
input?: object,
requestOptions?: AdtServerRequestOptions,
): Promise<T> => {
const path = definition.path.replace(
/\{([A-Za-z_$][A-Za-z0-9_$]*)\}/gu,
Expand All @@ -515,6 +558,7 @@ export function createAdtServerClient(options: AdtServerClientOptions) {
...options.headers,
},
...(definition.hasBody ? { body: JSON.stringify(body) } : {}),
signal: requestOptions?.signal,
});
const responseBody = await response.json().catch(() => undefined);
if (!response.ok) {
Expand All @@ -526,74 +570,141 @@ export function createAdtServerClient(options: AdtServerClientOptions) {
return {
buildTransportSourceManifest: (
params: BuildTransportSourceManifestParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<BuildTransportSourceManifestResponse>(
operationDefinitions.buildTransportSourceManifest,
params,
requestOptions,
),
getBadi: (
params: GetBadiParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<GetBadiResponse>(
operationDefinitions.getBadi,
params,
requestOptions,
),
getObjectMetadata: (params: GetObjectMetadataParams) =>
getObjectMetadata: (
params: GetObjectMetadataParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<GetObjectMetadataResponse>(
operationDefinitions.getObjectMetadata,
params,
requestOptions,
),
getObjectSourceHistory: (params: GetObjectSourceHistoryParams) =>
getObjectSourceHistory: (
params: GetObjectSourceHistoryParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<GetObjectSourceHistoryResponse>(
operationDefinitions.getObjectSourceHistory,
params,
requestOptions,
),
getPackageTree: (params: GetPackageTreeParams) =>
getPackageTree: (
params: GetPackageTreeParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<GetPackageTreeResponse>(
operationDefinitions.getPackageTree,
params,
requestOptions,
),
getTransportDetail: (params: GetTransportDetailParams) =>
getTransportDetail: (
params: GetTransportDetailParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<GetTransportDetailResponse>(
operationDefinitions.getTransportDetail,
params,
requestOptions,
),
listDestinations: (requestOptions?: AdtServerRequestOptions) =>
request<ListDestinationsResponse>(
operationDefinitions.listDestinations,
undefined,
requestOptions,
),
listDestinations: () =>
request<ListDestinationsResponse>(operationDefinitions.listDestinations),
listPackageObjects: (params: ListPackageObjectsParams) =>
listPackageObjects: (
params: ListPackageObjectsParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<ListPackageObjectsResponse>(
operationDefinitions.listPackageObjects,
params,
requestOptions,
),
listTransportObjects: (params: ListTransportObjectsParams) =>
listTransportObjects: (
params: ListTransportObjectsParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<ListTransportObjectsResponse>(
operationDefinitions.listTransportObjects,
params,
requestOptions,
),
listTransports: (params: ListTransportsParams) =>
listTransports: (
params: ListTransportsParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<ListTransportsResponse>(
operationDefinitions.listTransports,
params,
requestOptions,
),
readAtcFindingDocumentation: (params: ReadAtcFindingDocumentationParams) =>
readAtcFindingDocumentation: (
params: ReadAtcFindingDocumentationParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<ReadAtcFindingDocumentationResponse>(
operationDefinitions.readAtcFindingDocumentation,
params,
requestOptions,
),
readObjectSource: (params: ReadObjectSourceParams) =>
readObjectSource: (
params: ReadObjectSourceParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<ReadObjectSourceResponse>(
operationDefinitions.readObjectSource,
params,
requestOptions,
),
readSourceVersion: (params: ReadSourceVersionParams) =>
readSourceVersion: (
params: ReadSourceVersionParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<ReadSourceVersionResponse>(
operationDefinitions.readSourceVersion,
params,
requestOptions,
),
runAtc: (params: RunAtcParams) =>
request<RunAtcResponse>(operationDefinitions.runAtc, params),
searchObjects: (params: SearchObjectsParams) =>
runAtc: (params: RunAtcParams, requestOptions?: AdtServerRequestOptions) =>
request<RunAtcResponse>(
operationDefinitions.runAtc,
params,
requestOptions,
),
searchObjects: (
params: SearchObjectsParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<SearchObjectsResponse>(
operationDefinitions.searchObjects,
params,
requestOptions,
),
searchPackages: (params: SearchPackagesParams) =>
searchPackages: (
params: SearchPackagesParams,
requestOptions?: AdtServerRequestOptions,
) =>
request<SearchPackagesResponse>(
operationDefinitions.searchPackages,
params,
requestOptions,
),
};
}
Expand Down
53 changes: 53 additions & 0 deletions packages/adt-server-client/tests/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,56 @@ test('serializes generated path, query and JSON body inputs', async () => {
JSON.stringify({ sourceCapability: 'opaque-capability', maxBytes: 64 }),
);
});

test('passes isolated optional abort signals to parameterized and parameterless calls', async () => {
const requests: RequestInit[] = [];
const parameterizedController = new AbortController();
const parameterlessController = new AbortController();
const client = createAdtServerClient({
baseUrl: 'http://adt-server.test',
fetch: async (_input, init) => {
requests.push(init ?? {});
return new Response(JSON.stringify({ data: [], truncated: false }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
},
});

await client.getPackageTree(
{ destination: 'dev', root: 'ZROOT' },
{ signal: parameterizedController.signal },
);
await client.listDestinations({ signal: parameterlessController.signal });

assert.strictEqual(requests[0]?.signal, parameterizedController.signal);
assert.strictEqual(requests[1]?.signal, parameterlessController.signal);
assert.notStrictEqual(requests[0]?.signal, requests[1]?.signal);
});

test('aborts a generated request with its caller-provided signal', async () => {
const controller = new AbortController();
let observedSignal: AbortSignal | undefined;
const client = createAdtServerClient({
baseUrl: 'http://adt-server.test',
fetch: async (_input, init) => {
const signal = init?.signal;
if (!signal) {
throw new Error('Generated client did not forward a request signal');
}
observedSignal = signal;
return await new Promise<Response>((_resolve, reject) => {
signal.addEventListener('abort', () => {
reject(signal.reason);
});
});
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
},
});

const request = client.listDestinations({ signal: controller.signal });
controller.abort(new Error('caller cancelled'));

await assert.rejects(request, /caller cancelled/u);
assert.strictEqual(observedSignal, controller.signal);
assert.strictEqual(observedSignal?.aborted, true);
});
36 changes: 35 additions & 1 deletion packages/adt-server/src/request-handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import http from 'node:http';
import {
AdtResponseTooLargeError,
runWithAdtAbortSignal,
SourceVersionTooLargeError,
} from '@abapify/adt-client';
import { z } from 'zod';
Expand Down Expand Up @@ -1000,7 +1001,40 @@ async function dispatchRoute(
!(await ensureRestAuthorized(ctx, request, response))
)
return;
await route.handler(ctx, request, response, match);
if (!route.requiresRest) {
await route.handler(ctx, request, response, match);
return;
}
await runWithRequestAbortSignal(request, response, () =>
route.handler(ctx, request, response, match),
);
}

async function runWithRequestAbortSignal<T>(
request: http.IncomingMessage,
response: http.ServerResponse,
operation: () => Promise<T>,
): Promise<T> {
const abortController = new AbortController();
const abort = () => {
if (!abortController.signal.aborted) abortController.abort();
};
const abortOnPrematureResponseClose = () => {
if (!response.writableEnded) abort();
};

request.once('aborted', abort);
response.once('close', abortOnPrematureResponseClose);
if (request.aborted || (response.destroyed && !response.writableEnded)) {
abort();
}

try {
return await runWithAdtAbortSignal(abortController.signal, operation);
} finally {
request.removeListener('aborted', abort);
response.removeListener('close', abortOnPrematureResponseClose);
}
}

function handleServerError(
Expand Down
Loading
Loading