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
11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,22 @@
"@aligent/ts-code-standards": "^4.0.2",
"@types/node": "^22.16.5",
"diff": "^8.0.2",
"eslint": "^9.31.0",
"openapi-typescript": "^7.8.0",
"eslint": "^9.34.0",
"openapi-typescript": "^7.9.1",
"prettier": "^3.6.2",
"rimraf": "^6.0.1",
"simple-git": "^3.28.0",
"ts-morph": "^26.0.0",
"tsd": "^0.32.0",
"tsd": "^0.33.0",
"tshy": "^3.0.2",
"typescript": "^5.8.3",
"typescript": "^5.9.2",
"typescript-eslint": "^8.41.0",
"uuid": "^11.1.0"
},
"dependencies": {
"form-data": "^4.0.4",
"node-fetch": "^3.3.2",
"query-string": "^9.2.2"
},
"packageManager": "[email protected].2"
"packageManager": "[email protected].4"
}
120 changes: 89 additions & 31 deletions src/internal/operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ import { Agent as HttpsAgent } from 'https';
import fetch, { Response as FetchResponse } from 'node-fetch';
import qs from 'query-string';

// Represents HTTP methods supported by the API
/**
* @description HTTP methods supported by the API
*/
export type RequestMethod = 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';

// Represents a complete API request with its endpoint and parameters
/**
* @description Represents a complete API request with its endpoint and parameters
*/
export type Request<
ReqLine extends RequestLine = RequestLine,
Params extends Parameters = Parameters,
Expand All @@ -20,23 +24,37 @@ export type Request<
readonly parameters: Params;
};

// Represents an API endpoint in the format "METHOD /path"
// e.g., "GET /products" or "POST /orders"
/**
* @description Represents an API endpoint in the format "METHOD /path"
* e.g., "GET /products" or "POST /orders"
*/
export type RequestLine = `${RequestMethod} ${string}`;

// Represents all possible parameter types that can be sent with a request:
// - path: URL path parameters (e.g., /products/{id})
// - query: URL query parameters
// - body: Request body data
// - header: Custom HTTP headers
/**
* @description Represents all possible parameter types that can be sent with a request:
*/
export type Parameters = {
/**
* @description URL path parameters (e.g., /products/{id})
*/
readonly path?: Record<string, any>;
/**
* @description URL query parameters
*/
readonly query?: any;
/**
* @description Request body data
*/
readonly body?: any;
/**
* @description Custom HTTP headers
*/
readonly header?: Record<string, any>;
};

// Represents an API response with status code and optional body
/**
* @description Represents an API response with status code and optional body
*/
export type Response = {
readonly status: number | string;
readonly body?: any;
Expand All @@ -45,14 +63,20 @@ export type Response = {
// Namespace for response-related type utilities
export namespace Response {
type SuccessStatus = 200 | 201 | 204;

/**
* @description Extracts and formats the possible success responses for an operation
*/
export type Success<T extends Response | Operation> = T extends Operation
? Success<T['response']>
: T extends { status: SuccessStatus }
? T
: never;
}

// Represents a complete API operation with its parameters and expected response
/**
* @description Represents a complete API operation with its parameters and expected response
*/
export type Operation = {
readonly parameters: Request['parameters'];
readonly response: Response;
Expand All @@ -61,43 +85,59 @@ export type Operation = {
// Namespace for operation-related type utilities
// TODO: MI-199 - determine if this is still required
export namespace Operation {
// Extracts the minimal required input parameters for an operation
/**
* @description Extracts the minimal required input parameters for an operation
*/
export type MinimalInput<Op extends Operation> = InputParameters<Op['parameters']>;

// Transforms API parameters based on their type:
// - query parameters are made optional (Partial)
// - header parameters have Accept and Content-Type removed since they're handled by the client
// - all other parameters (path, body) are kept as-is
// TODO: MI-199 should we be making params partial?
type TransformParam<
/**
* @description Makes properties optional if they're on the 'query' object
*/
type MakeQueryParamsOptional<
OpParams extends Operation['parameters'],
K extends keyof OpParams,
> = K extends 'query' ? Partial<OpParams[K]> : OpParams[K];

// Transforms operation parameters to make certain fields optional
/**
* @description Makes properties optional if they can be empty objects
*/
type MakeEmptyObjectOptional<T> = {
readonly [K in keyof T as {} extends T[K] ? K : never]?: T[K];
} & {
readonly [K in keyof T as {} extends T[K] ? never : K]: T[K];
};

/**
* @description Prepares request parameters by making query params and any possibly empty params optional
*/
type InputParameters<OpParams extends Operation['parameters']> = MakeEmptyObjectOptional<{
[K in keyof OpParams]: TransformParam<OpParams, K>;
[K in keyof OpParams]: MakeQueryParamsOptional<OpParams, K>;
}>;
}

// Maps request lines to their corresponding operations
/**
* @description Maps request lines to their corresponding operations
*/
export type OperationIndex = Record<string, Operation>;

// Namespace for operation index utilities
export namespace OperationIndex {
// Filters operations to only include those with optional parameters
/**
* @description Filters operations to only include those with optional parameters
*/
export type FilterOptionalParams<Ops extends OperationIndex> = {
[K in keyof Ops as {} extends Ops[K]['parameters'] ? K : never]: Ops[K];
};
}

// Utility type that makes properties optional if they can be empty objects
type MakeEmptyObjectOptional<T> = {
readonly [K in keyof T as {} extends T[K] ? K : never]?: T[K];
} & {
readonly [K in keyof T as {} extends T[K] ? never : K]: T[K];
};

/**
* @description Resolves the path for a request
* @example
* ```ts
* resolvePath('/products/{id}', { id: 123 })
* // returns '/products/123'
* ```
*/
export function resolvePath(parameterizedPath: string, pathParams: Record<string, any>): string {
return parameterizedPath
.split('/')
Expand All @@ -116,10 +156,14 @@ export function resolvePath(parameterizedPath: string, pathParams: Record<string
.join('/');
}

// Transport function type that handles making the actual API requests
/**
* @description Transport function type that handles making the actual API requests
*/
export type Transport = (requestLine: string, params?: Parameters) => Promise<Response>;

// Configuration options for the fetch-based transport
/**
* @description Configuration options for the fetch-based transport
*/
export type FetchTransportOptions = {
readonly baseUrl: string;
readonly headers: Record<string, string>;
Expand Down Expand Up @@ -165,6 +209,20 @@ const defaultRetryConfig: Exclude<FetchTransportOptions['retry'], boolean | unde
},
};

/**
* @description Creates a fetch transport function
* @example
* ```ts
* const transport = fetchTransport({
* baseUrl: 'https://api.bigcommerce.com/stores/1234567890/v3',
* headers: {
* 'X-Auth-Token': '1234567890',
* },
* });
* ```
* @param options - The options for the fetch transport
* @returns
*/
export function fetchTransport(options: FetchTransportOptions): Transport {
const { agent, baseUrl, headers, retry } = options;

Expand Down
121 changes: 89 additions & 32 deletions src/internal/reference/operation-inference.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,110 @@
// TECH DEBT: Work out if these eslint rules are reasonable in this context
/* eslint-disable @typescript-eslint/no-empty-object-type */
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { RequestMethod } from '../operation.js';
import type { Flatten, IsOptional } from '../type-utils.js';

// Takes an OpenAPI paths specification and converts it into our internal operation index format,
// removing any undefined endpoints and flattening the paths into a single type
/**
* @description Converts an OpenAPI paths specification into our internal operation index format
* - REST operations are combined with their parent path e.g. 'GET /path/a'
* - Parameters are kept, or defaulted to an empty object if not present
* - Response and Request objects are reformatted with generics
*
* Only application/json request and response bodies are included
*
* @example
* ```ts
* type A = InferOperationIndex<{
* '/path/a': {
* put: {
* parameters: { a: string };
* requestBody: { content: { 'application/json': { b: string } } };
* };
* };
* }>;
* // A is {
* // 'PUT /path/a': RequiredRequestBody<{
* // a: string;
* // },
* // unknown,
* // {
* // b: string;
* // }>
* // }
*/
export type InferOperationIndex<PathsSpec> = Flatten<{
[PathStr in keyof PathsSpec & string]: PathOperationIndex<PathStr, PathsSpec[PathStr]>;
}>;

// Takes a record of types and flattens them into a single intersection type
// This combines all the operations from different paths into one type
type Flatten<T extends Record<string, any>> = UnionToIntersection<T[keyof T]>;
type UnionToIntersection<T> = (T extends any ? (x: T) => any : never) extends (x: infer R) => any
? R
: never;

// Converts an OpenAPI 3.0 path specification into our operation index format
// Currently this only extracts type details for application/json request bodies
/**
* @description Formats the OpenAPI parameters, responses, and responseBody specs at a given path
* in preparation for flattening the paths in to a single index type
*
* @see InferOperationIndex for more information
*/
type PathOperationIndex<Path extends string, PathSpec> = {
[K in keyof PathSpec as K extends RequestMethodLc
? `${Uppercase<K>} ${Path}`
: never]: PathSpec[K] extends { parameters?: infer Params; responses?: infer Responses }
? PathSpec[K] extends {
requestBody: { content: { 'application/json': infer RequestBody } };
[K in keyof PathSpec as PathKey<K, Path>]: PathSpec[K] extends {
parameters?: infer Params;
responses?: infer Responses;
}
? {
parameters: Params & ParamsRequestBody<PathSpec[K]>;
response: ResponseUnion<Responses>;
}
? {
readonly parameters: (unknown extends Params ? {} : Params) & {
body: RequestBody;
};
readonly response: Response<Responses>;
}
: {
readonly parameters: unknown extends Params ? {} : Params;
readonly response: Response<Responses>;
}
: never;
};

// Lowercase version of our RequestMethod type
// Used for matching HTTP methods in OpenAPI specs which are typically lowercase
type RequestMethodLc = Lowercase<RequestMethod>;
/**
* @description Combines the REST operation method with the endpoint path to form a unique key
* @example
* ```ts
* type A = PathKey<'get', '/path/a'>
* // A is 'GET /path/a'
*/
type PathKey<K, Path extends string> =
K extends Lowercase<RequestMethod> ? `${Uppercase<K>} ${Path}` : never;

// Converts OpenAPI response specifications into our internal response format
type Response<ResponsesSpec> = {
/**
* @description Converts OpenAPI response specifications into a union of possible responses
* Only responses with a 'application/json' content type are included
*
* @example
* ```ts
* type A = Response<{
* 200: { content: { 'application/json': { a: string } } };
* 204: { content: { 'application/json': {} } };
* }>;
* // A is { status: 200, body: { a: string } } | { status: 204, body: {} }
* ```
*/
type ResponseUnion<ResponsesSpec> = {
[Status in keyof ResponsesSpec]: {
status: Status;
body: ResponsesSpec[Status] extends { content: { 'application/json': infer ResponseBody } }
? ResponseBody
: never;
};
}[keyof ResponsesSpec];

/**
* @description Extracts the request body from an OpenAPI specification
* - Keeps the optional or required nature of the request body from the original spec
* - Only includes 'application/json' content type request bodies
* - Returns a type with no body property if the original spec has no request body property (e.g. GET requests)
*
* @example
* ```ts
* type A = ParamsRequestBody<{
* requestBody: { content: { 'application/json': { a: string } } };
* }>;
* // A is { body: { a: string } }
* ```
*/
type ParamsRequestBody<PathSpec> = PathSpec extends {
requestBody?: { content: { 'application/json': infer RequestBody } };
}
? unknown extends RequestBody
? unknown
: IsOptional<PathSpec, 'requestBody'> extends true
? Partial<{ body: RequestBody }>
: { body: RequestBody }
: never;
Loading