diff --git a/.changeset/real-wombats-dream.md b/.changeset/real-wombats-dream.md new file mode 100644 index 00000000..df2357b5 --- /dev/null +++ b/.changeset/real-wombats-dream.md @@ -0,0 +1,5 @@ +--- +"swagger-typescript-api": minor +--- + +Supports immutable types with --make-immutable or makeImmutable diff --git a/index.ts b/index.ts index f3356e94..8492d27a 100644 --- a/index.ts +++ b/index.ts @@ -86,6 +86,11 @@ const generateCommand = defineCommand({ description: "generate readonly properties", default: codeGenBaseConfig.addReadonly, }, + "make-immutable": { + type: "boolean", + description: "makes all properties and values readonly", + default: codeGenBaseConfig.makeImmutable, + }, "another-array-type": { type: "boolean", description: "generate array types as Array (by default Type[])", diff --git a/src/configuration.ts b/src/configuration.ts index 1810bae2..557af3c2 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -30,6 +30,8 @@ const TsKeyword = { Enum: "enum", Interface: "interface", Array: "Array", + ReadonlyArray: "ReadonlyArray", + Readonly: "Readonly", Record: "Record", Intersection: "&", Union: "|", @@ -55,6 +57,8 @@ export class CodeGenConfig { generateUnionEnums = false; /** CLI flag */ addReadonly = false; + /** CLI flag */ + makeImmutable = false; enumNamesAsValues = false; /** parsed swagger schema from getSwaggerObject() */ @@ -225,12 +229,20 @@ export class CodeGenConfig { /** * $A[] or Array<$A> */ - ArrayType: (content: unknown) => { + ArrayType: ({ readonly, content }: Record) => { if (this.anotherArrayType) { - return this.Ts.TypeWithGeneric(this.Ts.Keyword.Array, [content]); + return this.Ts.TypeWithGeneric( + readonly ? this.Ts.Keyword.ReadonlyArray : this.Ts.Keyword.Array, + [content], + ); } - - return `${this.Ts.ExpressionGroup(content)}[]`; + return lodash + .compact([ + readonly && "readonly ", + this.Ts.ExpressionGroup(content), + "[]", + ]) + .join(""); }, /** * "$A" @@ -265,8 +277,16 @@ export class CodeGenConfig { /** * Record<$A1, $A2> */ - RecordType: (key: unknown, value: unknown) => - this.Ts.TypeWithGeneric(this.Ts.Keyword.Record, [key, value]), + RecordType: ({ readonly, key, value }: Record) => { + const record = this.Ts.TypeWithGeneric(this.Ts.Keyword.Record, [ + key, + value, + ]); + if (readonly) { + return this.Ts.TypeWithGeneric(this.Ts.Keyword.Readonly, [record]); + } + return record; + }, /** * readonly $key?:$value */ @@ -277,8 +297,14 @@ export class CodeGenConfig { /** * [key: $A1]: $A2 */ - InterfaceDynamicField: (key: unknown, value: unknown) => - `[key: ${key}]: ${value}`, + InterfaceDynamicField: ({ + readonly, + key, + value, + }: Record) => + lodash + .compact([readonly && "readonly ", `[key: ${key}]`, `: ${value}`]) + .join(""), /** * EnumName.EnumKey @@ -344,8 +370,11 @@ export class CodeGenConfig { /** * [$A1, $A2, ...$AN] */ - Tuple: (values: unknown[]) => { - return `[${values.join(", ")}]`; + Tuple: ({ + readonly, + values, + }: Record & { values: unknown[] }) => { + return `${readonly ? "readonly " : ""}[${values.join(", ")}]`; }, }; diff --git a/src/schema-parser/base-schema-parsers/array.ts b/src/schema-parser/base-schema-parsers/array.ts index ae84eef7..4f7c49ef 100644 --- a/src/schema-parser/base-schema-parsers/array.ts +++ b/src/schema-parser/base-schema-parsers/array.ts @@ -4,7 +4,10 @@ import { MonoSchemaParser } from "../mono-schema-parser.js"; export class ArraySchemaParser extends MonoSchemaParser { override parse() { let contentType; - const { type, description, items } = this.schema || {}; + const { type, description, items, readOnly } = this.schema || {}; + + const readonly = + (readOnly && this.config.addReadonly) || this.config.makeImmutable; if (Array.isArray(items) && type === SCHEMA_TYPES.ARRAY) { const tupleContent = []; @@ -15,12 +18,15 @@ export class ArraySchemaParser extends MonoSchemaParser { .getInlineParseContent(), ); } - contentType = this.config.Ts.Tuple(tupleContent); + contentType = this.config.Ts.Tuple({ + readonly, + values: tupleContent, + }); } else { const content = this.schemaParserFabric .createSchemaParser({ schema: items, schemaPath: this.schemaPath }) .getInlineParseContent(); - contentType = this.config.Ts.ArrayType(content); + contentType = this.config.Ts.ArrayType({ readonly, content }); } return { diff --git a/src/schema-parser/base-schema-parsers/discriminator.ts b/src/schema-parser/base-schema-parsers/discriminator.ts index 4fce9b17..b6fa257d 100644 --- a/src/schema-parser/base-schema-parsers/discriminator.ts +++ b/src/schema-parser/base-schema-parsers/discriminator.ts @@ -58,7 +58,7 @@ export class DiscriminatorSchemaParser extends MonoSchemaParser { "schemas", this.typeName, ]); - const { discriminator } = this.schema; + const { discriminator, readOnly } = this.schema; const mappingEntries = lodash.entries(discriminator.mapping); const ableToCreateMappingType = !skipMappingType && @@ -84,6 +84,9 @@ export class DiscriminatorSchemaParser extends MonoSchemaParser { const content = ts.IntersectionType([ ts.ObjectWrapper( ts.TypeField({ + readonly: + (readOnly && this.config.addReadonly) || + this.config.makeImmutable, key: ts.StringValue(discriminator.propertyName), value: "Key", }), @@ -127,6 +130,9 @@ export class DiscriminatorSchemaParser extends MonoSchemaParser { ts.IntersectionType([ ts.ObjectWrapper( ts.TypeField({ + readonly: + (mappingSchema.readOnly && this.config.addReadonly) || + this.config.makeImmutable, key: discriminator.propertyName, value: mappingUsageKey, }), diff --git a/src/schema-parser/base-schema-parsers/object.ts b/src/schema-parser/base-schema-parsers/object.ts index 6731c1e4..511ca226 100644 --- a/src/schema-parser/base-schema-parsers/object.ts +++ b/src/schema-parser/base-schema-parsers/object.ts @@ -73,7 +73,8 @@ export class ObjectSchemaParser extends MonoSchemaParser { name: fieldName, value: fieldValue, field: this.config.Ts.TypeField({ - readonly: readOnly && this.config.addReadonly, + readonly: + (readOnly && this.config.addReadonly) || this.config.makeImmutable, optional: !required, key: fieldName, value: fieldValue, @@ -101,10 +102,13 @@ export class ObjectSchemaParser extends MonoSchemaParser { $$raw: { additionalProperties }, description: "", isRequired: false, - field: this.config.Ts.InterfaceDynamicField( - interfaceKeysContent, - this.config.Ts.Keyword.Any, - ), + field: this.config.Ts.InterfaceDynamicField({ + readonly: + (additionalProperties.readOnly && this.config.addReadonly) || + this.config.makeImmutable, + key: interfaceKeysContent, + value: this.config.Ts.Keyword.Any, + }), }); } diff --git a/src/schema-parser/base-schema-parsers/primitive.ts b/src/schema-parser/base-schema-parsers/primitive.ts index 417350e6..2e513f02 100644 --- a/src/schema-parser/base-schema-parsers/primitive.ts +++ b/src/schema-parser/base-schema-parsers/primitive.ts @@ -4,9 +4,12 @@ import { MonoSchemaParser } from "../mono-schema-parser.js"; export class PrimitiveSchemaParser extends MonoSchemaParser { override parse() { let contentType = null; - const { additionalProperties, type, description, items } = + const { additionalProperties, type, description, items, readOnly } = this.schema || {}; + const readonly = + (readOnly && this.config.addReadonly) || this.config.makeImmutable; + if (type === this.config.Ts.Keyword.Object && additionalProperties) { const propertyNamesSchema = this.schemaUtils.getSchemaPropertyNamesSchema( this.schema, @@ -37,10 +40,11 @@ export class PrimitiveSchemaParser extends MonoSchemaParser { recordValuesContent = this.config.Ts.Keyword.Any; } - contentType = this.config.Ts.RecordType( - recordKeysContent, - recordValuesContent, - ); + contentType = this.config.Ts.RecordType({ + readonly, + key: recordKeysContent, + value: recordValuesContent, + }); } if (Array.isArray(type) && type.length) { @@ -51,13 +55,14 @@ export class PrimitiveSchemaParser extends MonoSchemaParser { } if (Array.isArray(items) && type === SCHEMA_TYPES.ARRAY) { - contentType = this.config.Ts.Tuple( - items.map((item) => + contentType = this.config.Ts.Tuple({ + readonly, + values: items.map((item) => this.schemaParserFabric .createSchemaParser({ schema: item, schemaPath: this.schemaPath }) .getInlineParseContent(), ), - ); + }); } return { diff --git a/src/schema-parser/schema-formatters.ts b/src/schema-parser/schema-formatters.ts index ccd57d7d..1c9db284 100644 --- a/src/schema-parser/schema-formatters.ts +++ b/src/schema-parser/schema-formatters.ts @@ -82,10 +82,13 @@ export class SchemaFormatters { ? this.config.Ts.ObjectWrapper( this.formatObjectContent(parsedSchema.content), ) - : this.config.Ts.RecordType( - this.config.Ts.Keyword.String, - this.config.Ts.Keyword.Any, - ), + : this.config.Ts.RecordType({ + readonly: + (parsedSchema.readOnly && this.config.addReadonly) || + this.config.makeImmutable, + key: this.config.Ts.Keyword.String, + value: this.config.Ts.Keyword.Any, + }), ), }; }, diff --git a/tests/spec/readonly-always/__snapshots__/basic.test.ts.snap b/tests/spec/readonly-always/__snapshots__/basic.test.ts.snap new file mode 100644 index 00000000..819b0b16 --- /dev/null +++ b/tests/spec/readonly-always/__snapshots__/basic.test.ts.snap @@ -0,0 +1,51 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`basic > immutable 1`] = ` +"/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +export interface Pet { + /** @format int64 */ + readonly id: number; + readonly name: string; + readonly tag?: string; + readonly list?: readonly string[]; + readonly obj?: Readonly>; + readonly multiple?: string | number; +} +" +`; + +exports[`basic > immutable another-array-type 1`] = ` +"/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +export interface Pet { + /** @format int64 */ + readonly id: number; + readonly name: string; + readonly tag?: string; + readonly list?: ReadonlyArray; + readonly obj?: Readonly>; + readonly multiple?: string | number; +} +" +`; diff --git a/tests/spec/readonly-always/basic.test.ts b/tests/spec/readonly-always/basic.test.ts new file mode 100644 index 00000000..db5edc6e --- /dev/null +++ b/tests/spec/readonly-always/basic.test.ts @@ -0,0 +1,51 @@ +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { generateApi } from "../../../src/index.js"; + +describe("basic", async () => { + let tmpdir = ""; + + beforeAll(async () => { + tmpdir = await fs.mkdtemp(path.join(os.tmpdir(), "swagger-typescript-api")); + }); + + afterAll(async () => { + await fs.rm(tmpdir, { recursive: true }); + }); + + test("immutable", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + makeImmutable: true, + generateClient: false, + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); + test("immutable another-array-type", async () => { + await generateApi({ + fileName: "schema", + input: path.resolve(import.meta.dirname, "schema.json"), + output: tmpdir, + silent: true, + makeImmutable: true, + generateClient: false, + anotherArrayType: true, + }); + + const content = await fs.readFile(path.join(tmpdir, "schema.ts"), { + encoding: "utf8", + }); + + expect(content).toMatchSnapshot(); + }); +}); diff --git a/tests/spec/readonly-always/schema.json b/tests/spec/readonly-always/schema.json new file mode 100644 index 00000000..70f23cac --- /dev/null +++ b/tests/spec/readonly-always/schema.json @@ -0,0 +1,71 @@ +{ + "swagger": "2.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore", + "description": "A sample API that uses a petstore as an example to demonstrate features in the swagger-2.0 specification", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "Swagger API Team" + }, + "license": { + "name": "MIT" + } + }, + "host": "petstore.swagger.io", + "basePath": "/api", + "schemes": ["http"], + "consumes": ["application/json"], + "produces": ["application/json"], + "paths": { + "/pets": { + "get": { + "description": "Returns all pets from the system that the user has access to", + "produces": ["application/json"], + "responses": { + "200": { + "description": "A list of pets.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + } + } + } + } + }, + "definitions": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "list": { + "type": "array", + "items": { + "type": "string" + }, + "optional": false + }, + "obj": { + "type": "object", + "additionalProperties": true + }, + "multiple": { + "type": ["string", "number"] + } + } + } + } +} diff --git a/types/index.ts b/types/index.ts index 42736d93..9f98f7f5 100644 --- a/types/index.ts +++ b/types/index.ts @@ -140,6 +140,10 @@ interface GenerateApiParamsBase { * generate readonly properties (default: false) */ addReadonly?: boolean; + /** + * makes all properties and values readonly even if the schema does not contain `readOnly` property or if `readOnly` is false. Creates immutable types. (default: false) + */ + makeImmutable?: boolean; primitiveTypeConstructs?: ( struct: PrimitiveTypeStruct, @@ -705,6 +709,7 @@ export interface GenerateApiConfiguration { extractRequestBody: boolean; httpClientType: "axios" | "fetch"; addReadonly: boolean; + makeImmutable: boolean; extractResponseBody: boolean; extractResponseError: boolean; extractEnums: boolean;