Skip to content

Commit

Permalink
feat: support readOnly and writeOnly schemas
Browse files Browse the repository at this point in the history
ref #387
fix #382
  • Loading branch information
cvereterra authored and Xiphe committed May 2, 2023
1 parent 4f66428 commit 57a4e01
Show file tree
Hide file tree
Showing 4 changed files with 282 additions and 40 deletions.
51 changes: 51 additions & 0 deletions src/codegen/__fixtures__/readOnlyWriteOnly.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
openapi: 3.0.2
info:
title: readOnlyWriteOnlyAPI
version: 1.0.0
paths:
/example:
get:
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/ExampleSchema"
description: OK
operationId: getExample
post:
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/ExampleSchema"
required: true
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/ExampleSchema"
description: OK
operationId: setExample
components:
schemas:
ExampleSchema:
description: ""
required:
- always_present
- read_only_prop
- write_only_prop
type: object
properties:
always_present:
description: ""
type: string
read_only_prop:
description: ""
type: string
readOnly: true
write_only_prop:
writeOnly: true
description: ""
type: string
224 changes: 184 additions & 40 deletions src/codegen/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const verbs = [
];

type ContentType = "json" | "form" | "multipart";
type OnlyMode = "readOnly" | "writeOnly";

const contentTypes: Record<string, ContentType> = {
"*/*": "json",
Expand Down Expand Up @@ -156,8 +157,22 @@ export function getReferenceName(obj: unknown) {
}
}

export function toIdentifier(s: string, upperFirst = false) {
let cc = _.camelCase(s);
const onlyModeSuffixes: Record<OnlyMode, string> = {
readOnly: "Read",
writeOnly: "Write",
};

function getOnlyModeSuffix(onlyMode?: OnlyMode) {
if (!onlyMode) return "";
return onlyModeSuffixes[onlyMode];
}

export function toIdentifier(
s: string,
upperFirst = false,
onlyMode?: OnlyMode
) {
let cc = _.camelCase(s) + getOnlyModeSuffix(onlyMode);
if (upperFirst) cc = _.upperFirst(cc);
if (cg.isValidIdentifier(cc)) return cc;
return "$" + cc;
Expand Down Expand Up @@ -267,13 +282,25 @@ export default class ApiGenerator {
public readonly isConverted = false
) {}

aliases: ts.TypeAliasDeclaration[] = [];
aliases: (ts.TypeAliasDeclaration | ts.InterfaceDeclaration)[] = [];

enumAliases: ts.Statement[] = [];
enumRefs: Record<string, { values: string; type: ts.TypeReferenceNode }> = {};

// Collect the types of all referenced schemas so we can export them later
refs: Record<string, ts.TypeReferenceNode> = {};
// Referenced schemas can be pointing at the following versions:
// - "none": The regular type/interface e.g. ExampleSchema
// - "readOnly": The readOnly version e.g. ExampleSchemaRead
// - "writeOnly": The writeOnly version e.g. ExampleSchemaWrite
refs: Record<
string,
{
[k in OnlyMode | "none"]:
| ts.TypeReferenceNode
| ts.InterfaceDeclaration
| undefined;
}
> = {};

// Keep track of already used type aliases
typeAliases: Record<string, number> = {};
Expand Down Expand Up @@ -334,27 +361,97 @@ export default class ApiGenerator {
/**
* Create a type alias for the schema referenced by the given ReferenceObject
*/
getRefAlias(obj: OpenAPIV3.ReferenceObject) {
getRefAlias(obj: OpenAPIV3.ReferenceObject, onlyMode?: OnlyMode) {
const { $ref } = obj;
let ref = this.refs[$ref];
if (!ref) {
if (!this.refs[$ref]) {
this.refs[$ref] = {
none: undefined,
readOnly: undefined,
writeOnly: undefined,
};
}
let ref = this.refs[$ref][onlyMode ?? "none"];

if (!this.refs[$ref]["none"]) {
const schema = this.resolve<SchemaObject>(obj);
const name = schema.title || getRefName($ref);
const identifier = toIdentifier(name, true);
const alias = this.getUniqueAlias(identifier);

ref = this.refs[$ref] = factory.createTypeReferenceNode(alias, undefined);
ref = this.refs[$ref]["none"] = factory.createTypeReferenceNode(
alias,
undefined
);

const type = this.getTypeFromSchema(schema);
this.aliases.push(
cg.createTypeAliasDeclaration({
modifiers: [cg.modifier.export],
name: alias,
type,
})
);
// Create a type if no readOnly or writeOnly properties found
if (!this.isSchemaReadOnly(schema) && !this.isSchemaWriteOnly(schema)) {
this.aliases.push(
cg.createTypeAliasDeclaration({
modifiers: [cg.modifier.export],
name: alias,
type,
})
);
} else {
// Create an interface to be extended
this.aliases.push(
cg.createIntefaceAliasDeclaration({
modifiers: [cg.modifier.export],
name: alias,
type,
})
);
}
}
if (onlyMode === "readOnly" && !this.refs[$ref]["readOnly"]) {
const schema = this.resolve<SchemaObject>(obj);
const name = schema.title || getRefName($ref);
if (this.isSchemaReadOnly(schema)) {
const readOnlyAlias = this.getUniqueAlias(
toIdentifier(name, true, "readOnly")
);
ref = this.refs[$ref]["readOnly"] = factory.createTypeReferenceNode(
readOnlyAlias,
undefined
);

const readOnlyType = this.getTypeFromSchema(schema, name, "readOnly");
this.aliases.push(
cg.createIntefaceAliasDeclaration({
modifiers: [cg.modifier.export],
name: readOnlyAlias,
type: readOnlyType,
inheritedNodeNames: [name],
})
);
}
}
return ref;
if (onlyMode === "writeOnly" && !this.refs[$ref]["writeOnly"]) {
const schema = this.resolve<SchemaObject>(obj);
const name = schema.title || getRefName($ref);
if (this.isSchemaWriteOnly(schema)) {
const writeOnlyAlias = this.getUniqueAlias(
toIdentifier(name, true, "writeOnly")
);
ref = this.refs[$ref]["writeOnly"] = factory.createTypeReferenceNode(
writeOnlyAlias,
undefined
);
const writeOnlyType = this.getTypeFromSchema(schema, name, "writeOnly");
this.aliases.push(
cg.createIntefaceAliasDeclaration({
modifiers: [cg.modifier.export],
name: writeOnlyAlias,
type: writeOnlyType,
inheritedNodeNames: [name],
})
);
}
}

// If not ref fallback to the regular reference
return ref ?? this.refs[$ref]["none"];
}

getUnionType(
Expand Down Expand Up @@ -429,9 +526,10 @@ export default class ApiGenerator {
*/
getTypeFromSchema(
schema?: SchemaObject | OpenAPIV3.ReferenceObject,
name?: string
name?: string,
onlyMode?: OnlyMode
) {
const type = this.getBaseTypeFromSchema(schema, name);
const type = this.getBaseTypeFromSchema(schema, name, onlyMode);
return isNullable(schema)
? factory.createUnionTypeNode([type, cg.keywordType.null])
: type;
Expand All @@ -443,11 +541,12 @@ export default class ApiGenerator {
*/
getBaseTypeFromSchema(
schema?: SchemaObject | OpenAPIV3.ReferenceObject,
name?: string
name?: string,
onlyMode?: OnlyMode
): ts.TypeNode {
if (!schema) return cg.keywordType.any;
if (isReference(schema)) {
return this.getRefAlias(schema);
return this.getRefAlias(schema, onlyMode) as ts.TypeReferenceNode;
}

if (schema.oneOf) {
Expand Down Expand Up @@ -487,7 +586,8 @@ export default class ApiGenerator {
return this.getTypeFromProperties(
schema.properties || {},
schema.required,
schema.additionalProperties
schema.additionalProperties,
onlyMode
);
}
if (schema.enum) {
Expand Down Expand Up @@ -593,6 +693,30 @@ export default class ApiGenerator {
return type;
}

/**
* Check if schema is readOnly or has readOnly properties
*/
isSchemaReadOnly(schema: SchemaObject | OpenAPIV3.ReferenceObject) {
if ("$ref" in schema) return false;
if (schema.readOnly) return true;
if (!schema.properties) return false;
return Object.values(schema.properties).some((property) =>
"$ref" in property ? false : property.readOnly
);
}

/**
* Check if schema is writeOnly or has writeOnly properties
*/
isSchemaWriteOnly(schema: SchemaObject | OpenAPIV3.ReferenceObject) {
if ("$ref" in schema) return false;
if (schema.writeOnly) return true;
if (!schema.properties) return false;
return Object.values(schema.properties).some((property) =>
"$ref" in property ? false : property.writeOnly
);
}

/**
* Recursively creates a type literal with the given props.
*/
Expand All @@ -604,21 +728,33 @@ export default class ApiGenerator {
additionalProperties?:
| boolean
| OpenAPIV3.SchemaObject
| OpenAPIV3.ReferenceObject
| OpenAPIV3.ReferenceObject,
onlyMode?: OnlyMode
): ts.TypeLiteralNode {
const members: ts.TypeElement[] = Object.keys(props).map((name) => {
const schema = props[name];
const isRequired = required && required.includes(name);
let type = this.getTypeFromSchema(schema, name);
if (!isRequired && this.opts.unionUndefined) {
type = factory.createUnionTypeNode([type, cg.keywordType.undefined]);
}
return cg.createPropertySignature({
questionToken: !isRequired,
name,
type,
const members: ts.TypeElement[] = Object.keys(props)
.filter((name) => {
const schema = props[name];
if (!onlyMode)
return (
!this.isSchemaReadOnly(schema) && !this.isSchemaWriteOnly(schema)
);
if (onlyMode === "readOnly") return this.isSchemaReadOnly(schema);
else if (onlyMode === "writeOnly")
return this.isSchemaWriteOnly(schema);
})
.map((name) => {
const schema = props[name];
const isRequired = required && required.includes(name);
let type = this.getTypeFromSchema(schema, name);
if (!isRequired && this.opts.unionUndefined) {
type = factory.createUnionTypeNode([type, cg.keywordType.undefined]);
}
return cg.createPropertySignature({
questionToken: !isRequired,
name,
type,
});
});
});
if (additionalProperties) {
const type =
additionalProperties === true
Expand All @@ -630,7 +766,10 @@ export default class ApiGenerator {
return factory.createTypeLiteralNode(members);
}

getTypeFromResponses(responses: OpenAPIV3.ResponsesObject) {
getTypeFromResponses(
responses: OpenAPIV3.ResponsesObject,
onlyMode?: OnlyMode
) {
return factory.createUnionTypeNode(
Object.entries(responses).map(([code, res]) => {
const statusType =
Expand All @@ -645,7 +784,7 @@ export default class ApiGenerator {
}),
];

const dataType = this.getTypeFromResponse(res);
const dataType = this.getTypeFromResponse(res, onlyMode);
if (dataType !== cg.keywordType.void) {
props.push(
cg.createPropertySignature({
Expand All @@ -660,11 +799,16 @@ export default class ApiGenerator {
}

getTypeFromResponse(
resOrRef: OpenAPIV3.ResponseObject | OpenAPIV3.ReferenceObject
resOrRef: OpenAPIV3.ResponseObject | OpenAPIV3.ReferenceObject,
onlyMode?: OnlyMode
) {
const res = this.resolve(resOrRef);
if (!res || !res.content) return cg.keywordType.void;
return this.getTypeFromSchema(this.getSchemaFromContent(res.content));
return this.getTypeFromSchema(
this.getSchemaFromContent(res.content),
undefined,
onlyMode
);
}

getResponseType(
Expand Down Expand Up @@ -865,7 +1009,7 @@ export default class ApiGenerator {
if (requestBody) {
body = this.resolve(requestBody);
const schema = this.getSchemaFromContent(body.content);
const type = this.getTypeFromSchema(schema);
const type = this.getTypeFromSchema(schema, undefined, "writeOnly");
bodyVar = toIdentifier(
(type as any).name || getReferenceName(schema) || "body"
);
Expand Down Expand Up @@ -1015,7 +1159,7 @@ export default class ApiGenerator {
args,
returnType === "json" || returnType === "blob"
? [
this.getTypeFromResponses(responses!) ||
this.getTypeFromResponses(responses!, "readOnly") ||
ts.SyntaxKind.AnyKeyword,
]
: undefined
Expand Down
Loading

0 comments on commit 57a4e01

Please sign in to comment.