Skip to content

Commit

Permalink
feat(cli): allow for mutation inputs in non-body locations
Browse files Browse the repository at this point in the history
  • Loading branch information
freakyfelt committed Jun 4, 2023
1 parent c8c6d4f commit abb569e
Show file tree
Hide file tree
Showing 6 changed files with 262 additions and 75 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import {
mutations,
queries,
widgetPaths,
} from "./__fixtures__/widgets.fixtures.js";
} from "../__fixtures__/widgets.fixtures.js";
import { OASDocument, RPCDocument } from "../types/index.js";
import { DocumentTransformer } from "./document-transformer.js";
import { OASDocument, RPCDocument } from "./types";

const healthCheck = {
"/health": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import intersect from "just-intersect";
import { OperationTransformer } from "./operation-transformer.js";
import { getDefaultResolver } from "./resolver.js";
import { getDefaultResolver } from "../resolver.js";
import {
Logger,
OASDocument,
PathsObject,
RPCDocument,
} from "./types/index.js";
} from "../types/index.js";
import { OperationTransformer } from "./operation-transformer.js";

/**
* Transforms an RPC document into an OAS document
Expand Down Expand Up @@ -61,7 +61,7 @@ export class DocumentTransformer {

for (const [operationId, operation] of Object.entries(rpc.mutations)) {
paths[`/mutations/${operationId}`] = {
post: this.transformer.transformMutationOperation(
post: await this.transformer.transformMutationOperation(
operationId,
operation
),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import safeGet from "just-safe-get";
import assert from "node:assert";
import test from "node:test";
import { schemas } from "../__fixtures__/schemas.js";
import {
components,
createWidgetOAS,
listUserWidgetsOAS,
operations,
} from "./__fixtures__/widgets.fixtures.js";
} from "../__fixtures__/widgets.fixtures.js";
import {
ParameterObject,
ReferenceObject,
SchemaObject,
} from "../types/oas.js";
import { OperationTransformer } from "./operation-transformer.js";
import { ParameterObject, ReferenceObject, SchemaObject } from "./types/oas.js";

const resolver = {
resolve: (ref: ReferenceObject) => {
Expand All @@ -23,16 +28,61 @@ const resolver = {
};
const transformer = new OperationTransformer({ resolver });

test("OperationTransformer#transformMutationOperation", (t) => {
t.test("transforms a mutation operation", () => {
test("OperationTransformer#transformMutationOperation", async (t) => {
await t.test("transforms a mutation operation", async () => {
assert.deepStrictEqual(
transformer.transformMutationOperation(
await transformer.transformMutationOperation(
"createWidget",
operations.createWidget
),
createWidgetOAS
);
});

await t.test(
"moves operations to the path parameters if specified",
async () => {
const actual = await transformer.transformMutationOperation(
"createWidget",
{
...operations.createWidget,
input: {
...operations.createWidget.input,
parameters: {
userId: {
in: "path",
},
},
},
}
);

const expectedParameters = [
{
name: "userId",
in: "path",
required: true,
schema: schemas.CreateWidgetInput.properties.userId,
},
];
const expectedRequestBody = {
content: {
"application/json": {
schema: {
type: "object",
required: ["status"],
properties: {
status: schemas.CreateWidgetInput.properties.status,
},
},
},
},
};

assert.deepStrictEqual(actual.parameters, expectedParameters);
assert.deepStrictEqual(actual.requestBody, expectedRequestBody);
}
);
});

test("OperationTransformer#transformQueryOperation", async (t) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import assert from "node:assert";
import { IResolver } from "./resolver.js";
import { IResolver } from "../resolver.js";
import {
MutationOperationObject,
OperationObject,
ParameterObject,
QueryInputObject,
QueryOperationObject,
RPCInputObject,
RPCOperationObject,
RPCOutputObject,
ResponseObject,
ResponsesObject,
assertObjectTypeSchema,
isReferenceObject,
} from "./types/index.js";
} from "../types/index.js";
import {
ParameterDefaults,
TransformOutput,
transformRPCInputs,
} from "./parameter-transformer.js";

const DEFAULT_RESPONSE: ResponseObject = {
description: "OK",
Expand All @@ -27,29 +32,11 @@ export class OperationTransformer {
this.resolver = resolver;
}

transformMutationOperation(
async transformMutationOperation(
operationId: string,
operation: QueryOperationObject
): OperationObject {
const { description, input, output, errors, ...rest } = operation;
const responses = this.transformOperationResponses(output, errors);

const requestBody = input
? {
required: true,
content: {
"application/json": input,
},
}
: undefined;

return {
operationId,
description,
requestBody,
responses,
...rest,
};
operation: MutationOperationObject
): Promise<OperationObject> {
return this.transformOperation(operationId, operation, { in: "body" });
}

/**
Expand Down Expand Up @@ -96,25 +83,34 @@ export class OperationTransformer {
async transformQueryOperation(
operationId: string,
operation: QueryOperationObject
): Promise<OperationObject> {
return this.transformOperation(operationId, operation, { in: "query" });
}

private async transformOperation(
operationId: string,
operation: RPCOperationObject,
parameterDefaults: ParameterDefaults
): Promise<OperationObject> {
const { description, input, output, errors, ...rest } = operation;
const parameters = input
? await this.transformQueryInput(input)
const transformedInputs = input
? await this.transformInput(input, parameterDefaults)
: undefined;
const responses = this.transformOperationResponses(output, errors);

return {
operationId,
description,
parameters,
...transformedInputs,
responses,
...rest,
};
}

async transformQueryInput(
input: QueryInputObject
): Promise<ParameterObject[]> {
private async transformInput(
input: RPCInputObject,
defaults: ParameterDefaults
): Promise<TransformOutput> {
const { schema: schemaOrRef, parameters: parameterOverrides = {} } = input;
assert(
typeof schemaOrRef === "object",
Expand All @@ -124,23 +120,11 @@ export class OperationTransformer {
const inputSchema = isReferenceObject(schemaOrRef)
? await this.resolver.resolve(schemaOrRef)
: schemaOrRef;
assertObjectTypeSchema(
inputSchema,
"Query input schema must be an object type with properties"
);

// @todo support parameter overrides object

return Object.entries(inputSchema.properties).map(([name, schema]) => ({
name,
in: "query",
schema,
...(inputSchema?.required?.includes(name) ? { required: true } : {}),
...parameterOverrides[name],
})) as ParameterObject[];
return transformRPCInputs(inputSchema, defaults, parameterOverrides);
}

transformOperationResponses(
private transformOperationResponses(
output?: RPCOutputObject,
errors?: ResponsesObject
): OperationObject["responses"] {
Expand Down
135 changes: 135 additions & 0 deletions packages/cli/src/transformers/parameter-transformer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import {
OperationObject,
ParameterObject,
RPCParameterLocation,
RPCParameterObject,
RPCParametersObject,
SchemaObject,
assertObjectTypeSchema,
} from "../types/index.js";

export type TransformOutput = Pick<
OperationObject,
"requestBody" | "parameters"
>;
export type ParameterDefaults = RPCParameterObject & {
in: RPCParameterLocation;
};

/**
* transformRPCInputs takes an RPC input schema and transforms it into an OpenAPI requestBody and parameters
*
* @example
* ```typescript
* const inputSchema = schemas.CreateWidgetInput;
* const defaults = { in: 'body' };
* const overrides = {
* userId: { in: 'path', required: true },
* };
* > transformRPCInputs(inputSchema, defaults, overrides);
* {
* requestBody: {
* required: true,
* content: {
* 'application/json': {
* schema: {
* type: 'object',
* required: ['status'],
* properties: {
* status: schemas.CreateWidgetInput.properties.status,
* },
* },
* },
* },
* },
* parameters: [
* {
* name: 'userId',
* in: 'path',
* required: true,
* schema: schemas.CreateWidgetInput.properties.userId,
* },
* ],
* }
* ```
*
* @param inputSchema
* @param defaults values to use for parameters that are not overridden, primarily the `in` location
* @param overrides
* @returns
*/
export function transformRPCInputs(
inputSchema: SchemaObject,
defaults: ParameterDefaults,
overrides: RPCParametersObject = {}
): TransformOutput {
assertObjectTypeSchema(
inputSchema,
"input schema must be an object type with properties"
);

if (Object.keys(overrides).length === 0 && defaults.in === "body") {
// easy case, no overrides and the input is a body
return {
requestBody: {
required: true,
content: {
"application/json": {
schema: inputSchema,
},
},
},
};
}

let hasParameters = false;
let hasBody = false;
const parameters: ParameterObject[] = [];
const bodySchema: SchemaObject = {
type: "object",
properties: {},
required: [],
};

for (const [name, schema] of Object.entries(inputSchema.properties)) {
const location = overrides[name]?.in ?? defaults.in;
if (location === "body") {
hasBody ||= true;

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
bodySchema.properties![name] = schema;
if (inputSchema.required?.includes(name)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
bodySchema.required!.push(name);
}
} else {
hasParameters ||= true;

parameters.push({
name,
// @ts-expect-error tsc thinks that "body" is still an option
in: location,
schema,
...(inputSchema.required?.includes(name) ? { required: true } : {}),
...overrides[name],
});
}
}

if (!hasBody && !hasParameters) {
return {};
} else if (!hasBody) {
return { parameters };
} else {
const requestBody = {
required: true,
content: {
"application/json": {
schema: bodySchema,
},
},
};

return { requestBody, parameters };
}
}
Loading

0 comments on commit abb569e

Please sign in to comment.