@aep_dev/aep-lib-ts
Version:
Utility libraries for AEP TypeScript-based tools including case conversion, OpenAPI utilities, and API clients
414 lines (365 loc) • 12.2 kB
text/typescript
import {
API,
Contact,
OpenAPI,
PatternInfo,
Resource,
APISchema,
Response as OpenAPIResponse,
RequestBody as OpenAPIRequestBody,
CustomMethod,
} from "./types.js";
import { pascalCaseToKebabCase } from "../cases/cases.js";
import { Schema } from "../openapi/types.js";
import { logger } from "../utils/logger.js";
export class APIClient {
private api: API;
constructor(api: API) {
this.api = api;
}
static async fromOpenAPI(
openAPI: OpenAPI,
serverURL: string = "",
pathPrefix: string = ""
): Promise<APIClient> {
if (!openAPI.openapi && !openAPI.openapi) {
throw new Error(
"Unable to detect OAS openapi. Please add an openapi field or a openapi field"
);
}
logger.info(`reading openapi file: ${openAPI.openapi}`);
const resourceBySingular: Record<string, Resource> = {};
const customMethodsByPattern: Record<string, CustomMethod[]> = {};
// Parse paths to find possible resources
for (const [path, pathItem] of Object.entries(openAPI.paths)) {
const trimmedPath = path.slice(pathPrefix.length);
const patternInfo = getPatternInfo(trimmedPath);
if (!patternInfo) {
continue;
}
let schemaRef: APISchema | null = null;
const r: Partial<Resource> = {};
if (patternInfo.customMethodName && patternInfo.isResourcePattern) {
const pattern = trimmedPath.split(":")[0].slice(1);
if (!customMethodsByPattern[pattern]) {
customMethodsByPattern[pattern] = [];
}
if (pathItem.post) {
const response = pathItem.post.responses["200"];
if (response) {
const schema = getSchemaFromResponse(response, openAPI);
const responseSchema = schema
? await dereferenceSchema(schema, openAPI)
: null;
if (!pathItem.post.requestBody) {
throw new Error(
`Custom method ${patternInfo.customMethodName} has a POST response but no request body`
);
}
const requestSchema = await dereferenceSchema(
getSchemaFromRequestBody(pathItem.post.requestBody, openAPI),
openAPI
);
customMethodsByPattern[pattern].push({
name: patternInfo.customMethodName,
method: "POST",
request: requestSchema,
response: responseSchema,
});
}
}
if (pathItem.get) {
const response = pathItem.get.responses["200"];
if (response) {
const schema = getSchemaFromResponse(response, openAPI);
const responseSchema = schema
? await dereferenceSchema(schema, openAPI)
: null;
customMethodsByPattern[pattern].push({
name: patternInfo.customMethodName,
method: "GET",
request: null,
response: responseSchema,
});
}
}
} else if (patternInfo.isResourcePattern) {
if (pathItem.delete) {
r.deleteMethod = {};
}
if (pathItem.get) {
const response = pathItem.get.responses["200"];
if (response) {
schemaRef = getSchemaFromResponse(response, openAPI);
r.getMethod = {};
}
}
if (pathItem.patch) {
const response = pathItem.patch.responses["200"];
if (response) {
schemaRef = getSchemaFromResponse(response, openAPI);
r.updateMethod = {};
}
}
} else {
if (pathItem.post) {
const response = pathItem.post.responses["200"];
if (response) {
schemaRef = getSchemaFromResponse(response, openAPI);
const supportsUserSettableCreate =
pathItem.post.parameters?.some((param) => param.name === "id") ??
false;
r.createMethod = { supportsUserSettableCreate };
}
}
if (pathItem.get) {
const response = pathItem.get.responses["200"];
if (response) {
const respSchema = getSchemaFromResponse(response, openAPI);
if (!respSchema) {
console.warn(
`Resource ${path} has a LIST method with a response schema, but the response schema is null.`
);
} else {
const resolvedSchema = await dereferenceSchema(
respSchema,
openAPI
);
const arrayProperty = Object.entries(
resolvedSchema.properties || {}
).find(([_, prop]) => prop.type === "array");
if (arrayProperty) {
schemaRef = arrayProperty[1].items || null;
r.listMethod = {
hasUnreachableResources: false,
supportsFilter: false,
supportsSkip: false,
};
for (const param of pathItem.get.parameters || []) {
if (param.name === "skip") {
r.listMethod.supportsSkip = true;
} else if (param.name === "unreachable") {
r.listMethod.hasUnreachableResources = true;
} else if (param.name === "filter") {
r.listMethod.supportsFilter = true;
}
}
} else {
console.warn(
`Resource ${path} has a LIST method with a response schema, but the items field is not present or is not an array.`
);
}
}
}
}
}
if (schemaRef) {
const parts = schemaRef.$ref?.split("/") || [];
const key = parts[parts.length - 1];
const singular = pascalCaseToKebabCase(key);
const pattern = trimmedPath.split("/").slice(1);
if (!patternInfo.isResourcePattern) {
let finalSingular = singular;
if (pattern.length >= 3) {
const parent = pattern[pattern.length - 3].slice(1, -1); // Remove curly braces
if (singular.startsWith(parent)) {
finalSingular = singular.slice(parent.length + 1);
}
}
pattern.push(`{${finalSingular}}`);
}
const dereferencedSchema = await dereferenceSchema(schemaRef, openAPI);
const resource = await getOrPopulateResource(
singular,
pattern,
dereferencedSchema,
resourceBySingular,
openAPI
);
if (r.getMethod) resource.getMethod = r.getMethod;
if (r.listMethod) resource.listMethod = r.listMethod;
if (r.createMethod) resource.createMethod = r.createMethod;
if (r.updateMethod) resource.updateMethod = r.updateMethod;
if (r.deleteMethod) resource.deleteMethod = r.deleteMethod;
}
}
// Map custom methods to resources
for (const [pattern, customMethods] of Object.entries(
customMethodsByPattern
)) {
const resource = Object.values(resourceBySingular).find(
(r) => r.patternElems.join("/") === pattern
);
if (resource) {
resource.customMethods = customMethods;
}
}
if (!serverURL) {
serverURL = openAPI.servers[0]?.url + pathPrefix;
}
if (serverURL == "" || serverURL == "undefined") {
throw new Error("No server URL found in openapi, and none was provided");
}
// Add non-resource schemas to API's schemas
const schemas: Record<string, APISchema> = {};
for (const [key, schema] of Object.entries(openAPI.components.schemas)) {
if (!resourceBySingular[key]) {
schemas[key] = schema;
}
}
return new APIClient({
serverURL,
name: openAPI.info.title,
contact: getContact(openAPI.info.contact),
resources: resourceBySingular,
schemas,
});
}
resources(): Record<string, Resource> {
return this.api.resources;
}
getResource(resource: string): Resource {
const r = this.api.resources[resource];
if (!r) {
throw new Error(`Resource "${resource}" not found`);
}
return r;
}
serverUrl(): string {
return this.api.serverURL;
}
}
function getPatternInfo(path: string): PatternInfo | null {
let customMethodName = "";
if (path.includes(":")) {
const parts = path.split(":");
path = parts[0];
customMethodName = parts[1];
}
const pattern = path.split("/").slice(1);
for (let i = 0; i < pattern.length; i++) {
const segment = pattern[i];
const wrapped = segment.startsWith("{") && segment.endsWith("}");
const wantWrapped = i % 2 === 1;
if (wrapped !== wantWrapped) {
return null;
}
}
return {
isResourcePattern: pattern.length % 2 === 0,
customMethodName,
};
}
async function getOrPopulateResource(
singular: string,
pattern: string[],
schema: Schema,
resourceBySingular: Record<string, Resource>,
openAPI: OpenAPI
): Promise<Resource> {
if (resourceBySingular[singular]) {
return resourceBySingular[singular];
}
let resource: Resource;
if (schema["x-aep-resource"]) {
resource = {
singular: schema["x-aep-resource"].singular!,
plural: schema["x-aep-resource"].plural!,
// Parents will be set later on.
parents: [],
children: [],
patternElems: schema["x-aep-resource"].patterns![0].split("/").filter(Boolean),
schema,
customMethods: [],
};
} else {
resource = {
singular,
plural: "",
parents: [],
children: [],
patternElems: pattern,
schema,
customMethods: [],
};
}
if (schema) {
if (schema["x-aep-resource"] && schema["x-aep-resource"].parents) {
for (const parentSingular of schema["x-aep-resource"].parents) {
const parentSchema = openAPI.components.schemas[parentSingular];
if (!parentSchema) {
throw new Error(
`Resource "${singular}" parent "${parentSingular}" not found`
);
}
const parentResource = await getOrPopulateResource(
parentSingular,
[],
parentSchema,
resourceBySingular,
openAPI
);
resource.parents.push(parentResource);
parentResource.children.push(resource);
}
}
}
resourceBySingular[singular] = resource;
return resource;
}
function getContact(contact?: Contact): Contact | null {
if (!contact || (!contact.name && !contact.email && !contact.url)) {
return null;
}
return contact;
}
function getSchemaFromResponse(
response: OpenAPIResponse,
openAPI: OpenAPI
): APISchema | null {
if (openAPI.openapi === "2.0") {
return response.schema || null;
}
return response.content?.["application/json"]?.schema || null;
}
function getSchemaFromRequestBody(
requestBody: OpenAPIRequestBody,
openAPI: OpenAPI
): APISchema {
if (openAPI.openapi === "2.0") {
return requestBody.schema!;
}
return requestBody.content["application/json"].schema!;
}
async function dereferenceSchema(
schema: APISchema,
openAPI: OpenAPI
): Promise<APISchema> {
if (!schema.$ref) {
return schema;
}
if (schema.$ref.startsWith("http://") || schema.$ref.startsWith("https://")) {
logger.debug(
`Fetching external schema from ${schema.$ref}...`);
const response = await fetch(schema.$ref);
if (!response.ok) {
throw new Error(`Failed to fetch external schema: ${schema.$ref}`);
}
const externalSchema = await response.json() as APISchema;
logger.debug(
`Final schema fetched from ${schema.$ref}: ${JSON.stringify(externalSchema)}`);
return dereferenceSchema(externalSchema, openAPI);
}
const parts = schema.$ref.split("/");
const key = parts[parts.length - 1];
let childSchema: APISchema;
if (openAPI.openapi === "2.0") {
childSchema = openAPI.definitions![key];
} else {
childSchema = openAPI.components.schemas[key];
}
if (!childSchema) {
throw new Error(`Schema "${schema.$ref}" not found`);
}
return dereferenceSchema(childSchema, openAPI);
}