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
2 changes: 1 addition & 1 deletion typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -923,7 +923,7 @@ npm run build
# Run tests
npm test

# Type check
# Type check (src via tsconfig.json, then tests and scripts via tsconfig.test.json)
npm run typecheck

# Lint
Expand Down
2 changes: 1 addition & 1 deletion typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"typecheck": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit",
"lint": "oxlint src --ignore-pattern 'src/generated/**'"
},
"keywords": [
Expand Down
80 changes: 53 additions & 27 deletions typescript/scripts/generate-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,23 +838,14 @@ function groupOperations(spec: OpenAPISpec): Map<string, ServiceDefinition> {
const tag = operation.tags?.[0] || "Untagged";
const parsed = parseOperation(path, method, operation);

// Determine service
let serviceName: string;
if (SERVICE_SPLITS[tag]) {
let found = false;
for (const [svc, opIds] of Object.entries(SERVICE_SPLITS[tag])) {
if (opIds.includes(operation.operationId)) {
serviceName = svc;
found = true;
break;
}
}
if (!found) {
serviceName = TAG_TO_SERVICE[tag] || tag.replace(/\s+/g, "");
}
} else {
serviceName = TAG_TO_SERVICE[tag] || tag.replace(/\s+/g, "");
}
// Determine service: a split tag routes the operation to the first
// sub-service that names it, and anything unlisted falls to the tag's
// own service.
const split = SERVICE_SPLITS[tag];
const splitService = split
? Object.entries(split).find(([, opIds]) => opIds.includes(operation.operationId))?.[0]
: undefined;
const serviceName = splitService ?? (TAG_TO_SERVICE[tag] || tag.replace(/\s+/g, ""));

if (!services.has(serviceName)) {
services.set(serviceName, {
Expand Down Expand Up @@ -1328,9 +1319,7 @@ function generateMethod(op: ParsedOperation, serviceName: string): string[] {
} else if (isPaginated) {
lines.push(` return this.requestPaginated(`);
} else if (isWrappedPaginated) {
const entitySchema = findUnderlyingEntitySchema(op.responseSchemaRef || "", op.paginationKey);
const entityName = entitySchema && TYPE_ALIASES[entitySchema] ? TYPE_ALIASES[entitySchema][0] : "unknown";
lines.push(` return this.requestPaginatedWrapped<"${op.paginationKey}", ${entityName}>(`);
lines.push(` return this.requestPaginatedWrapped<"${op.paginationKey}", ${buildPaginationElementType(op)}>(`);
} else {
lines.push(` const response = await this.request(`);
}
Expand Down Expand Up @@ -1487,6 +1476,36 @@ function buildMethodSignature(op: ParsedOperation, resourceName: string): {
};
}

/**
* The element type to put inside `ListResult<...>` for a paginated operation.
*
* Both paginated shapes resolve the same way, one level apart: a bare array
* response takes its own `items`, a wrapped response takes the `items` of the
* property named by the pagination key. The name is the friendly alias when the
* entity has one, otherwise the item's own schema ref — degrading to a schema
* ref keeps the element concrete where a missing TYPE_ALIASES entry used to
* cost the whole `ListResult` wrapper (array form) or the element type
* (wrapped form, which spelled `unknown`). Only a schema that names no element
* at all reaches `unknown` now.
*
* Callers must use this for BOTH the declared return type and the
* `requestPaginatedWrapped` type argument, which have to agree.
*/
function buildPaginationElementType(op: ParsedOperation): string {
const responseSchema = op.responseSchemaRef ? globalSchemas[op.responseSchemaRef] : undefined;
const listSchema = op.returnsArray
? responseSchema
: op.paginationKey
? responseSchema?.properties?.[op.paginationKey]
: undefined;

const itemRef = listSchema?.items?.$ref ? resolveRef(listSchema.items.$ref) : "";
if (!itemRef) return "unknown";

const alias = TYPE_ALIASES[itemRef];
return alias ? alias[0] : `components["schemas"]["${itemRef}"]`;
Comment thread
jeremy marked this conversation as resolved.
}

function buildReturnType(op: ParsedOperation, serviceName: string): string {
if (op.returnsVoid) return "void";

Expand All @@ -1499,9 +1518,7 @@ function buildReturnType(op: ParsedOperation, serviceName: string): string {
const parts: string[] = [];
for (const [propName, propSchema] of Object.entries(schema.properties)) {
if (propName === op.paginationKey) {
const entitySchema = findUnderlyingEntitySchema(op.responseSchemaRef, op.paginationKey);
const entityName = entitySchema && TYPE_ALIASES[entitySchema] ? TYPE_ALIASES[entitySchema][0] : "unknown";
parts.push(`${propName}: ListResult<${entityName}>`);
parts.push(`${propName}: ListResult<${buildPaginationElementType(op)}>`);
} else {
const propType = propSchema.$ref
? (() => {
Expand All @@ -1523,7 +1540,13 @@ function buildReturnType(op: ParsedOperation, serviceName: string): string {
}
return op.returnsArray ? `${entityName}[]` : entityName;
}
// Fallback to schema ref
// No friendly name: the entity has no TYPE_ALIASES entry. Fall back to the
// response schema ref — but a paginated array still comes back as a
// ListResult at runtime (requestPaginated builds one), so the wrapper has to
// survive the fallback. Only the element name degrades, to its schema ref.
if (op.returnsArray && op.hasPagination) {
return `ListResult<${buildPaginationElementType(op)}>`;
}
return `components["schemas"]["${op.responseSchemaRef}"]`;
}

Expand Down Expand Up @@ -1752,6 +1775,9 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href)
main();
}

// Exported for generator regression tests.
export { generateExampleValue, setSchemas };
export type { Schema };
// Exported for generator regression tests. `generateMethod` is here because
// the declared return type is only half of a wrapped-paginated signature —
// nothing but the emitted method shows the `requestPaginatedWrapped` type
// argument, and the two have to name the same element.
export { generateExampleValue, setSchemas, buildReturnType, generateMethod };
export type { Schema, ParsedOperation };
2 changes: 1 addition & 1 deletion typescript/src/generated/services/checkins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export class CheckinsService extends BaseService {
* const result = await client.checkins.reminders();
* ```
*/
async reminders(options?: RemindersCheckinOptions): Promise<components["schemas"]["GetQuestionRemindersResponseContent"]> {
async reminders(options?: RemindersCheckinOptions): Promise<ListResult<components["schemas"]["QuestionReminder"]>> {
return this.requestPaginated(
{
service: "Checkins",
Expand Down
4 changes: 2 additions & 2 deletions typescript/src/generated/services/gauges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ export class GaugesService extends BaseService {
* const filtered = await client.gauges.listGaugeNeedles(123, { page: 1 });
* ```
*/
async listGaugeNeedles(projectId: number, options?: ListGaugeNeedlesGaugeOptions): Promise<components["schemas"]["ListGaugeNeedlesResponseContent"]> {
async listGaugeNeedles(projectId: number, options?: ListGaugeNeedlesGaugeOptions): Promise<ListResult<components["schemas"]["GaugeNeedle"]>> {
return this.requestPaginated(
{
service: "Gauges",
Expand Down Expand Up @@ -286,7 +286,7 @@ export class GaugesService extends BaseService {
* const filtered = await client.gauges.listGauges({ bucketIds: "example" });
* ```
*/
async listGauges(options?: ListGaugesGaugeOptions): Promise<components["schemas"]["ListGaugesResponseContent"]> {
async listGauges(options?: ListGaugesGaugeOptions): Promise<ListResult<components["schemas"]["Gauge"]>> {
return this.requestPaginated(
{
service: "Gauges",
Expand Down
2 changes: 1 addition & 1 deletion typescript/src/generated/services/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export class SearchService extends BaseService {
* const result = await client.search.search("q");
* ```
*/
async search(q: string, options?: SearchSearchOptions): Promise<components["schemas"]["SearchResponseContent"]> {
async search(q: string, options?: SearchSearchOptions): Promise<ListResult<components["schemas"]["SearchResult"]>> {
return this.requestPaginated(
{
service: "Search",
Expand Down
28 changes: 18 additions & 10 deletions typescript/tests/auth-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ import { createBasecampClient } from "../src/client.js";

const BASE_URL = "https://3.basecampapi.com/12345";

// Request capture below uses `const captured: { request?: Request } = {}` rather
// than a `let capturedRequest: Request | null = null`. The assignment happens
// inside an MSW handler closure that TypeScript's control-flow analysis cannot
// see, so the `let` form stays narrowed to `null` and every later read of it is
// typed `never`. Holding the value on an object defeats that narrowing without
// weakening anything: if the handler never runs, `captured.request` is still
// `undefined` and the header assertions still fail.

describe("BearerAuth", () => {
it("sets Authorization header with static token", async () => {
const auth = bearerAuth("my-token");
Expand Down Expand Up @@ -45,11 +53,11 @@ describe("Custom AuthStrategy", () => {
});

it("works with createBasecampClient via auth option", async () => {
let capturedRequest: Request | null = null;
const captured: { request?: Request } = {};

server.use(
http.get(`${BASE_URL}/projects.json`, ({ request }) => {
capturedRequest = request;
captured.request = request;
return HttpResponse.json([]);
})
);
Expand All @@ -67,8 +75,8 @@ describe("Custom AuthStrategy", () => {

await client.GET("/projects.json");

expect(capturedRequest?.headers.get("X-Custom-Auth")).toBe("custom-value");
expect(capturedRequest?.headers.get("Authorization")).toBeNull();
expect(captured.request?.headers.get("X-Custom-Auth")).toBe("custom-value");
expect(captured.request?.headers.get("Authorization")).toBeNull();
});
});

Expand All @@ -90,11 +98,11 @@ describe("createBasecampClient auth validation", () => {
});

it("accepts accessToken for backward compatibility", async () => {
let capturedRequest: Request | null = null;
const captured: { request?: Request } = {};

server.use(
http.get(`${BASE_URL}/projects.json`, ({ request }) => {
capturedRequest = request;
captured.request = request;
return HttpResponse.json([]);
})
);
Expand All @@ -106,17 +114,17 @@ describe("createBasecampClient auth validation", () => {

await client.GET("/projects.json");

expect(capturedRequest?.headers.get("Authorization")).toBe(
expect(captured.request?.headers.get("Authorization")).toBe(
"Bearer compat-token"
);
});

it("accepts auth option with BearerAuth", async () => {
let capturedRequest: Request | null = null;
const captured: { request?: Request } = {};

server.use(
http.get(`${BASE_URL}/projects.json`, ({ request }) => {
capturedRequest = request;
captured.request = request;
return HttpResponse.json([]);
})
);
Expand All @@ -128,7 +136,7 @@ describe("createBasecampClient auth validation", () => {

await client.GET("/projects.json");

expect(capturedRequest?.headers.get("Authorization")).toBe(
expect(captured.request?.headers.get("Authorization")).toBe(
"Bearer auth-option-token"
);
});
Expand Down
Loading
Loading