diff --git a/src/autorest.bicep/package-lock.json b/src/autorest.bicep/package-lock.json index 6e55c5e25a..805af21619 100644 --- a/src/autorest.bicep/package-lock.json +++ b/src/autorest.bicep/package-lock.json @@ -13,6 +13,7 @@ "@types/lodash": "^4.17.0", "autorest": "^3.7.1", "bicep-types": "file:../../bicep-types/src/bicep-types", + "json-schema": "^0.4.0", "lodash": "^4.17.21" }, "devDependencies": { @@ -26,6 +27,7 @@ "eslint-plugin-header": "^3.1.1", "eslint-plugin-jest": "^27.4.2", "jest": "^27.5.1", + "json-diff": "^1.0.6", "ts-jest": "^27.1.4", "ts-node": "^10.9.1", "typescript": "^4.9.5" @@ -774,6 +776,15 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@ewoudenberg/difflib": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@ewoudenberg/difflib/-/difflib-0.1.0.tgz", + "integrity": "sha512-OU5P5mJyD3OoWYMWY+yIgwvgNS9cFAU10f+DDuvtogcWQOoJIsQ4Hy2McSfUfhKjq8L0FuWVb4Rt7kgA+XK86A==", + "dev": true, + "dependencies": { + "heap": ">= 0.2.0" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -1425,6 +1436,12 @@ "pretty-format": "^27.0.0" } }, + "node_modules/@types/json-diff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/json-diff/-/json-diff-1.0.0.tgz", + "integrity": "sha512-dCXC1F73Sqriz2d8Wt/sP/DztE+rlfIRPxW9WSYheHp/l3gvkeSvM6l4vhm7t4Dgn8AJAxNKajx/eobbPdP6Wg==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.12", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", @@ -2665,6 +2682,18 @@ "node": ">=8" } }, + "node_modules/dreamopt": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz", + "integrity": "sha512-vyJTp8+mC+G+5dfgsY+r3ckxlz+QMX40VjPQsZc5gxVAxLmi64TBoVkP54A/pRAXMXsbu2GMMBrZPxNv23waMg==", + "dev": true, + "dependencies": { + "wordwrap": ">=0.0.2" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.71", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.71.tgz", @@ -3445,6 +3474,12 @@ "node": ">=8" } }, + "node_modules/heap": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", + "dev": true + }, "node_modules/html-encoding-sniffer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", @@ -5077,12 +5112,34 @@ "node": ">=4" } }, + "node_modules/json-diff": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/json-diff/-/json-diff-1.0.6.tgz", + "integrity": "sha512-tcFIPRdlc35YkYdGxcamJjllUhXWv4n2rK9oJ2RsAzV4FBkuV4ojKEDgcZ+kpKxDmJKv+PFK65+1tVVOnSeEqA==", + "dev": true, + "dependencies": { + "@ewoudenberg/difflib": "0.1.0", + "colors": "^1.4.0", + "dreamopt": "~0.8.0" + }, + "bin": { + "json-diff": "bin/json-diff.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -6438,6 +6495,12 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -7115,6 +7178,15 @@ "integrity": "sha512-mjZVbpaeMZludF2fsWLD0Z9gCref1Tk4i9+wddjRvpUNqqcndPkBD09N/Mapey0b3jaXbLm2kICwFv2E64QinA==", "dev": true }, + "@ewoudenberg/difflib": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@ewoudenberg/difflib/-/difflib-0.1.0.tgz", + "integrity": "sha512-OU5P5mJyD3OoWYMWY+yIgwvgNS9cFAU10f+DDuvtogcWQOoJIsQ4Hy2McSfUfhKjq8L0FuWVb4Rt7kgA+XK86A==", + "dev": true, + "requires": { + "heap": ">= 0.2.0" + } + }, "@humanwhocodes/config-array": { "version": "0.11.13", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz", @@ -7681,6 +7753,12 @@ "pretty-format": "^27.0.0" } }, + "@types/json-diff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@types/json-diff/-/json-diff-1.0.0.tgz", + "integrity": "sha512-dCXC1F73Sqriz2d8Wt/sP/DztE+rlfIRPxW9WSYheHp/l3gvkeSvM6l4vhm7t4Dgn8AJAxNKajx/eobbPdP6Wg==", + "dev": true + }, "@types/json-schema": { "version": "7.0.12", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", @@ -8570,6 +8648,15 @@ } } }, + "dreamopt": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/dreamopt/-/dreamopt-0.8.0.tgz", + "integrity": "sha512-vyJTp8+mC+G+5dfgsY+r3ckxlz+QMX40VjPQsZc5gxVAxLmi64TBoVkP54A/pRAXMXsbu2GMMBrZPxNv23waMg==", + "dev": true, + "requires": { + "wordwrap": ">=0.0.2" + } + }, "electron-to-chromium": { "version": "1.4.71", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.71.tgz", @@ -9136,6 +9223,12 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "heap": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/heap/-/heap-0.2.7.tgz", + "integrity": "sha512-2bsegYkkHO+h/9MGbn6KWcE45cHZgPANo5LXF7EvWdT0yT2EguSVO1nDgU5c8+ZOPwp2vMNa7YFsJhVcDR9Sdg==", + "dev": true + }, "html-encoding-sniffer": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", @@ -10427,12 +10520,28 @@ "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", "dev": true }, + "json-diff": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/json-diff/-/json-diff-1.0.6.tgz", + "integrity": "sha512-tcFIPRdlc35YkYdGxcamJjllUhXWv4n2rK9oJ2RsAzV4FBkuV4ojKEDgcZ+kpKxDmJKv+PFK65+1tVVOnSeEqA==", + "dev": true, + "requires": { + "@ewoudenberg/difflib": "0.1.0", + "colors": "^1.4.0", + "dreamopt": "~0.8.0" + } + }, "json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true }, + "json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, "json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -11416,6 +11525,12 @@ "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "dev": true }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true + }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/src/autorest.bicep/package.json b/src/autorest.bicep/package.json index 882a82bc7c..0558064dca 100644 --- a/src/autorest.bicep/package.json +++ b/src/autorest.bicep/package.json @@ -5,6 +5,7 @@ "scripts": { "build": "tsc -p .", "test": "jest", + "test:fix": "BASELINE_RECORD=true jest", "start": "node ./dist/src/main.js", "lint": "eslint src --ext ts", "lint:fix": "eslint src --ext ts --fix" @@ -15,6 +16,7 @@ "@types/lodash": "^4.17.0", "autorest": "^3.7.1", "bicep-types": "file:../../bicep-types/src/bicep-types", + "json-schema": "^0.4.0", "lodash": "^4.17.21" }, "devDependencies": { @@ -28,6 +30,7 @@ "eslint-plugin-header": "^3.1.1", "eslint-plugin-jest": "^27.4.2", "jest": "^27.5.1", + "json-diff": "^1.0.6", "ts-jest": "^27.1.4", "ts-node": "^10.9.1", "typescript": "^4.9.5" diff --git a/src/autorest.bicep/src/main.ts b/src/autorest.bicep/src/main.ts index cf97c74350..b96e91720c 100755 --- a/src/autorest.bicep/src/main.ts +++ b/src/autorest.bicep/src/main.ts @@ -3,6 +3,7 @@ import { AutoRestExtension, AutorestExtensionHost, startSession } from "@autorest/extension-base"; import { generateTypes } from "./type-generator"; +import { generateSchema } from "./schema-generator"; import { CodeModel, codeModelSchema } from "@autorest/codemodel"; import { writeTypesJson, writeMarkdown } from "bicep-types"; import { getProviderDefinitions } from "./resources"; @@ -18,15 +19,23 @@ export async function processRequest(host: AutorestExtensionHost) { for (const definition of getProviderDefinitions(session.model, host)) { const { namespace, apiVersion } = definition; - const types = generateTypes(host, definition); const outFolder = `${namespace}/${apiVersion}`.toLowerCase(); - // write types.json - host.writeFile({ filename: `${outFolder}/types.json`, content: writeTypesJson(types) }); - - // writer types.md - host.writeFile({ filename: `${outFolder}/types.md`, content: writeMarkdown(types, `${namespace} @ ${apiVersion}`) }); + if (!session.configuration["arm-schema"]) { + const types = generateTypes(host, definition); + + // write types.json + host.writeFile({ filename: `${outFolder}/types.json`, content: writeTypesJson(types) }); + + // writer types.md + host.writeFile({ filename: `${outFolder}/types.md`, content: writeMarkdown(types, `${namespace} @ ${apiVersion}`) }); + } else { + const schema = generateSchema(host, definition); + + // write schema.json + host.writeFile({ filename: `${outFolder}/schema.json`, content: JSON.stringify(schema, null, 2) }); + } } session.info(`autorest.bicep took ${Date.now() - start}ms`); diff --git a/src/autorest.bicep/src/resources.ts b/src/autorest.bicep/src/resources.ts index 17fb0bbc67..9e1cbe9f58 100644 --- a/src/autorest.bicep/src/resources.ts +++ b/src/autorest.bicep/src/resources.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { ChoiceSchema, CodeModel, ComplexSchema, HttpMethod, HttpParameter, HttpRequest, HttpResponse, ImplementationLocation, isObjectSchema, ObjectSchema, Operation, Parameter, ParameterLocation, Request, Response, Schema, SchemaResponse, SealedChoiceSchema, Metadata } from "@autorest/codemodel"; +import { ChoiceSchema, CodeModel, ComplexSchema, HttpMethod, HttpParameter, HttpRequest, HttpResponse, ImplementationLocation, isObjectSchema, ObjectSchema, Operation, Parameter, ParameterLocation, Request, Response, Schema, SchemaResponse, SealedChoiceSchema, Metadata, ConstantSchema } from "@autorest/codemodel"; import { Channel, AutorestExtensionHost } from "@autorest/extension-base"; import { keys, Dictionary, values, groupBy, uniqBy, chain, flatten } from 'lodash'; import { success, failure, Result } from './utils'; @@ -115,6 +115,7 @@ export function getSerializedName(metadata: Metadata) { interface ParameterizedName { type: 'parameterized'; schema: Schema; + description?: string; } interface ConstantName { @@ -146,7 +147,11 @@ export function getNameSchema(request: HttpRequest, parameters: Parameter[]): Re return failure(`Unable to locate parameter with name '${resNameParam}'`); } - return success({type: 'parameterized', schema: param.schema}); + if (param.schema instanceof ConstantSchema) { + return success({ type: 'constant', value: param.schema.value.value.toString() }); + } + + return success({type: 'parameterized', schema: param.schema, description: param.language.default.description }); } if (!/^[a-zA-Z0-9]*$/.test(resNameParam)) { diff --git a/src/autorest.bicep/src/schema-generator.ts b/src/autorest.bicep/src/schema-generator.ts new file mode 100644 index 0000000000..aa7320ad54 --- /dev/null +++ b/src/autorest.bicep/src/schema-generator.ts @@ -0,0 +1,718 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { AutorestExtensionHost, Channel } from '@autorest/extension-base'; +import { JSONSchema4, JSONSchema4TypeName } from 'json-schema'; +import { chain, Dictionary, escapeRegExp, keys, orderBy, uniq } from 'lodash'; +import { getFullyQualifiedType, getNameSchema, getSerializedName, NameSchema, ProviderDefinition, ResourceDefinition, ResourceDescriptor } from "./resources"; +import { isEmpty } from 'lodash'; +import { ScopeType } from "bicep-types"; +import { AnyObjectSchema, AnySchema, ArraySchema, ByteArraySchema, ChoiceSchema, ComplexSchema, ConstantSchema, DateTimeSchema, DictionarySchema, NumberSchema, ObjectSchema, PrimitiveSchema, Property, Schema, SchemaType, SealedChoiceSchema, StringSchema, UuidSchema } from '@autorest/codemodel'; +import { failure, success } from './utils'; + +interface SchemaData { + definitions: Dictionary; + rgResources: Dictionary; + subResources: Dictionary; + mgResources: Dictionary; + tenantResources: Dictionary; + extensionResources: Dictionary; + unknownResources: Dictionary; +} + +export function generateSchema(host: AutorestExtensionHost, definition: ProviderDefinition): JSONSchema4 { + const schemaData: SchemaData = { + definitions: {}, + rgResources: {}, + subResources: {}, + mgResources: {}, + tenantResources: {}, + extensionResources: {}, + unknownResources: {}, + } + + function logWarning(message: string) { + host.message({ Channel: Channel.Warning, Text: message, }); + } + + function getResourcePath(definition: ResourceDefinition) { + return (definition.putOperation ?? definition.getOperation)?.request.path; + } + + function tryGetConstantName(nameSchema: NameSchema) { + if (nameSchema.type === 'constant') { + return nameSchema.value; + } + + if ((nameSchema.schema instanceof ChoiceSchema || nameSchema.schema instanceof SealedChoiceSchema) && + nameSchema.schema.choices.length === 1 && typeof nameSchema.schema.choices[0].value === 'string') + { + return nameSchema.schema.choices[0].value; + } + + return; + } + + function getResourceNameSchema(descriptor: ResourceDescriptor, nameSchema: NameSchema, isChildDefinition: boolean): JSONSchema4 { + const constName = tryGetConstantName(nameSchema); + + if (constName) { + if (descriptor.typeSegments.length < 2 || isChildDefinition) { + return addExpressionOneOf({ + type: 'string', + enum: [constName], + }); + } else { + return addExpressionOneOf({ + type: 'string', + pattern: `^.*/${escapeRegExp(constName)}$`, + }); + } + } + + return (nameSchema.type == 'parameterized' ? parseType(nameSchema.schema, true) : undefined) ?? { type: 'string' }; + } + + function createObject(properties: Dictionary, additionalProperties?: JSONSchema4): JSONSchema4 { + return { + type: 'object', + properties, + additionalProperties, + }; + } + + function throwIfNull(putSchema: TSchema | undefined) { + const output = putSchema; + if (!output) { + throw 'Unable to find PUT schema'; + } + + return output; + } + + function getSchemaProperties(schema: ObjectSchema, ancestorsToExclude?: Set): Dictionary { + const objects = [schema]; + for (const parent of schema.parents?.all || []) { + if (parent instanceof ObjectSchema) { + objects.push(parent); + } + } + + return chain(objects).filter(o => !(ancestorsToExclude?.has(o))).flatMap(o => o.properties || []).keyBy(p => p.serializedName).value(); + } + + function* getObjectTypeProperties(putSchema: ObjectSchema | undefined, ancestorsToExclude?: Set) { + const putProperties = putSchema ? getSchemaProperties(putSchema, ancestorsToExclude) : {}; + + for (const propertyName of keys(putProperties)) { + if (putSchema?.discriminator?.property && putSchema.discriminator.property === putProperties[propertyName]) { + continue; + } + + const putProperty = putProperties[propertyName]; + + // exclude readonly properties - they cannot be set in template schemas + if (isReadOnly(putProperty)) { + continue; + } + + yield { propertyName, putProperty }; + } + } + + function getTypeDescription(schema: Schema | undefined) { + return schema?.language.default.description || undefined; + } + + function getPropertyDescription(putProperty: Property | undefined) { + const propertyDescription = putProperty?.language.default.description; + + return propertyDescription ?? getTypeDescription(putProperty?.schema); + } + + function flattenDiscriminatorSubTypes(schema: ObjectSchema | undefined) { + if (!schema || !schema.discriminator) { + return {}; + } + + const output: Dictionary = {}; + for (const key in schema.discriminator.all) { + const value = schema.discriminator.all[key]; + + if (!(value instanceof ObjectSchema)) { + throw `Unable to flatten discriminated properties - schema '${getSerializedName(schema)}' has non-object discriminated value '${getSerializedName(value)}'.`; + } + + if (!value.discriminator) { + output[key] = value; + continue; + } + + if (schema.discriminator.property.serializedName !== value.discriminator.property.serializedName) { + throw `Unable to flatten discriminated properties - schemas '${getSerializedName(schema)}' and '${getSerializedName(value)}' have conflicting discriminators '${schema.discriminator.property.serializedName}' and '${value.discriminator.property.serializedName}'`; + } + + const subTypes = flattenDiscriminatorSubTypes(value); + for (const subTypeKey in subTypes) { + output[subTypeKey] = subTypes[subTypeKey]; + } + } + + return output; + } + + function* getDiscriminatedSubTypes(putSchema: ObjectSchema | undefined) { + const putSubTypes = flattenDiscriminatorSubTypes(putSchema); + + for (const subTypeName of uniq([...keys(putSubTypes)])) { + yield { + subTypeName, + putSubType: putSubTypes[subTypeName], + }; + } + } + + function parseType(putSchema: Schema | undefined, supportsExpression: boolean) { + const schema = parseTypeInternal(putSchema); + + if (schema === undefined || + isEmpty(schema) || + (schema.type === 'string' && !schema.enum && !schema.pattern) || + !supportsExpression) { + return schema; + } + + return addExpressionOneOf(schema); + } + + function addExpressionOneOf(schema: JSONSchema4) { + return { + oneOf: [ + schema, + { + '$ref': 'https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression', + } + ] + }; + } + + function parseTypeInternal(putSchema: Schema | undefined): JSONSchema4 | undefined { + // A schema that matches a JSON object with specific properties, such as + // { "name": { "type": "string" }, "age": { "type": "number" } } + if (putSchema instanceof ObjectSchema) { + return parseObjectType(putSchema as ObjectSchema); + } + + // A schema that matches a "dictionary" JSON object, such as + // { "additionalProperties": { "type": "string" } } + if (putSchema instanceof DictionarySchema) { + return parseDictionaryType(putSchema as DictionarySchema); + } + + // A schema that matches a single value from a given set of values, such as + // { "enum": [ "a", "b" ] } + if (putSchema instanceof ChoiceSchema) { + return parseEnumType(putSchema as ChoiceSchema); + } + if (putSchema instanceof SealedChoiceSchema) { + return parseEnumType(putSchema as SealedChoiceSchema); + } + if (putSchema instanceof ConstantSchema) { + return parseConstant(putSchema as ConstantSchema); + } + + // A schema that matches an array of values, such as + // { "items": { "type": "number" } } + if (putSchema instanceof ArraySchema) { + return parseArrayType(putSchema as ArraySchema); + } + + // A schema that matches simple values (or that is serialized to simple values), such + // as { "type": "number" } or { "type": "string", "format": "base64url" } + if (putSchema instanceof PrimitiveSchema || (putSchema instanceof ByteArraySchema && putSchema.format !== 'byte')) { + return parsePrimaryType(putSchema as PrimitiveSchema); + } + + if (putSchema instanceof AnyObjectSchema) { + return { + type: 'object', + }; + } + + // The 'any' type + if (putSchema instanceof AnySchema) { + return {}; + } + + logWarning(`Unrecognized property type: ${putSchema?.type}. Returning 'any'.`); + return {}; + } + + function toBuiltInTypeKind(schema: PrimitiveSchema): JSONSchema4TypeName { + switch (schema.type) { + case SchemaType.Boolean: + return 'boolean'; + case SchemaType.Integer: + case SchemaType.UnixTime: + return 'integer'; + case SchemaType.Number: + return 'number' + case SchemaType.Object: + return 'object'; + case SchemaType.ByteArray: + // TODO implement https://github.com/Azure/autorest.azureresourceschema/blob/14a17d2c77e3c0c1180ca19ee59a3b2f414eaf93/src/ResourceSchemaParser.cs#L644-L647 + return (schema as ByteArraySchema).format === 'base64url' + ? 'string' + : 'array'; + case SchemaType.Uri: + case SchemaType.Date: + case SchemaType.DateTime: + case SchemaType.Time: + case SchemaType.String: + case SchemaType.Uuid: + case SchemaType.Duration: + case SchemaType.Credential: + case SchemaType.ArmId: + return 'string'; + default: + logWarning(`Unrecognized known property type: "${schema.type}"`); + return 'any'; + } + } + + function parsePrimaryType(putSchema: PrimitiveSchema | undefined): JSONSchema4 { + const combinedSchema = throwIfNull(putSchema); + + if (combinedSchema instanceof DateTimeSchema) { + return { + type: 'string', + format: combinedSchema.format, + }; + } + + if (combinedSchema instanceof UuidSchema) { + return { + type: 'string', + pattern: '^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$' + }; + } + + if (combinedSchema instanceof StringSchema) { + return { + type: 'string', + pattern: combinedSchema.pattern, + minLength: combinedSchema.minLength, + maxLength: combinedSchema.maxLength, + }; + } + + if (combinedSchema instanceof NumberSchema) { + return { + type: combinedSchema.type, + minimum: combinedSchema.minimum, + maximum: combinedSchema.maximum, + multipleOf: combinedSchema.multipleOf, + } + } + + return { + type: toBuiltInTypeKind(combinedSchema), + }; + } + + function handlePolymorphicType(schema: JSONSchema4, putSchema?: ObjectSchema) { + const ancestorsToExclude: Set = new Set([...(putSchema?.parents?.all || [])]); + if (putSchema) { + ancestorsToExclude.add(putSchema); + } + + const oneOf: JSONSchema4[] = []; + for (const { putSubType } of getDiscriminatedSubTypes(putSchema)) { + const childSchema = parseObjectType(putSubType, ancestorsToExclude); + if (childSchema.type !== 'object') { + logWarning(`Found unexpected element of discriminated type '${childSchema.type}'`); + continue; + } + + oneOf.push(childSchema); + } + + schema.oneOf = oneOf; + } + + function processResourceBody(fullyQualifiedType: string, definition: ResourceDefinition, isChildDefinition: boolean) { + const { descriptor, putOperation } = definition; + + if (!putOperation) { + return + } + + const r = getNameSchema(putOperation.request, putOperation.parameters); + if (!r.success) { + logWarning(`Skipping resource type ${fullyQualifiedType} under path '${putOperation.request.path}': ${r.error}`); + return + } + + const name = getResourceNameSchema(descriptor, r.value, isChildDefinition); + if (r.value.type === 'parameterized') { + name.description = r.value.description; + } + + const schema: JSONSchema4 = { + type: 'object', + description: getFullyQualifiedType(descriptor), + properties: { + name, + }, + required: [ + 'name' + ], + }; + + const putSchema = putOperation.requestSchema; + if (!putSchema) { + return schema; + } + + for (const { propertyName, putProperty } of getObjectTypeProperties(putSchema)) { + if (schema.properties![propertyName]) { + continue; + } + + const propertyDefinition = parseType(putProperty?.schema, true); + if (propertyDefinition !== undefined) { + const description = getPropertyDescription(putProperty); + schema.properties![propertyName] = { + ...propertyDefinition, + description, + }; + } + } + + if (schema?.discriminator) { + handlePolymorphicType(schema, putSchema); + } + + // TODO this is just here for parity with autorest.azureresourceschema - we sholud consider relaxing it in future. + if (schema.properties && schema.properties['properties']) { + schema.required = Array.isArray(schema.required) ? schema.required : []; + if (schema.required.indexOf('properties') < 0) { + schema.required.push('properties'); + } + } + + return schema; + } + + function addResourceTypeAndApiVersion(descriptor: ResourceDescriptor, schema: JSONSchema4, isChildDefinition: boolean): JSONSchema4 { + schema.properties ??= {}; + schema.required ??= []; + + schema.properties['apiVersion'] = { + type: 'string', + enum: [descriptor.apiVersion], + }; + + const typeName = isChildDefinition ? descriptor.typeSegments[descriptor.typeSegments.length - 1] : getFullyQualifiedType(descriptor) + schema.properties['type'] = { + type: 'string', + enum: [typeName], + }; + + schema.required = uniq([ + ...(schema.required as string[]), + 'apiVersion', + 'type' + ]); + + return schema; + } + + function processResource(fullyQualifiedType: string, definitions: ResourceDefinition[], isChildDefinition: boolean) { + const descriptor = definitions[0].descriptor; + if (definitions.length > 1) { + for (const definition of definitions) { + if (!definition.descriptor.constantName) { + logWarning(`Skipping resource type ${fullyQualifiedType} under path '${getResourcePath(definitions[0])}': Found multiple definitions for the same type`); + return null; + } + } + + const oneOf: JSONSchema4[] = []; + for (const definition of definitions) { + const bodyType = processResourceBody(fullyQualifiedType, definition, isChildDefinition); + if (!bodyType || !definition.descriptor.constantName) { + return null; + } + + oneOf.push(bodyType); + } + + const schema: JSONSchema4 = { + oneOf: oneOf, + }; + + return { + descriptor: { + ...descriptor, + constantName: undefined, + }, + schema: addResourceTypeAndApiVersion(descriptor, schema, isChildDefinition), + }; + } else { + const definition = definitions[0]; + const schema = processResourceBody(fullyQualifiedType, definition, isChildDefinition); + if (!schema) { + return null; + } + + return { + descriptor, + schema: addResourceTypeAndApiVersion(descriptor, schema, isChildDefinition), + }; + } + } + + function getObjectName(putSchema: ObjectSchema | undefined) { + return putSchema ? getSerializedName(putSchema) : undefined; + } + + function parseObjectType(putSchema: ObjectSchema | undefined, ancestorsToExclude?: Set) { + putSchema = throwIfNull(putSchema); + const definitionName = getObjectName(putSchema); + + if (!ancestorsToExclude && schemaData.definitions[definitionName]) { + // if we're building a discriminated subtype, we're going to be missing the base properties + // so construct the type on-the-fly, and don't cache it globally + return { + '$ref': `#/definitions/${definitionName}`, + }; + } + + const lookupParentDictionary = (s: ObjectSchema | undefined) => chain(s?.parents?.all || []) + .filter(s => { + if (ancestorsToExclude && ancestorsToExclude.has(s)) { + return false; + } + + return s instanceof DictionarySchema; + }) + .map(s => s as DictionarySchema) + .first() + .value(); + + const putParentDictionary = lookupParentDictionary(putSchema); + const additionalProperties = putParentDictionary ? parseType(putParentDictionary?.elementType, true) : undefined; + + const definition = createObject({}, additionalProperties); + definition.description = getTypeDescription(putSchema); + if (!ancestorsToExclude) { + // cache the definition so that it can be re-used + schemaData.definitions[definitionName] = definition; + } + + for (const { propertyName, putProperty } of getObjectTypeProperties(putSchema, ancestorsToExclude)) { + const propertyDefinition = parseType(putProperty?.schema, true); + if (propertyDefinition !== undefined) { + const description = getPropertyDescription(putProperty); + definition.properties![propertyName] = { + ...propertyDefinition, + description, + }; + + if (putProperty?.required) { + const oldRequired = Array.isArray(definition.required) ? definition.required : []; + definition.required = [...oldRequired, propertyName]; + } + } + } + + if (putSchema.discriminator) { + handlePolymorphicType(definition, putSchema); + } + + if (!ancestorsToExclude) { + return { + '$ref': `#/definitions/${definitionName}`, + }; + } + + return definition; + } + + function getValuesForEnum(schema: ChoiceSchema | SealedChoiceSchema) { + if (!(schema.choiceType instanceof StringSchema)) { + // we can only handle string enums right now + return failure('Only string enums can be converted to union types'); + } + + return success({ + values: schema.choices.map(c => c.value.toString()), + closed: schema instanceof SealedChoiceSchema + }); + } + + function parseEnumType(putSchema: ChoiceSchema | SealedChoiceSchema | undefined): JSONSchema4 | undefined { + const combinedSchema = throwIfNull(putSchema); + + const enumValues = getValuesForEnum(combinedSchema); + if (!enumValues.success) { + return parseType(combinedSchema.choiceType, true); + } + + const { values } = enumValues.value; + + // TODO handle 'open' enums? + + return { + type: 'string', + enum: values, + }; + } + + function parseConstant(putSchema: ConstantSchema | undefined): JSONSchema4 { + const combinedSchema = throwIfNull(putSchema); + const constantValue = combinedSchema.value; + + return { + type: 'string', + enum: [constantValue.value.toString()], + }; + } + + function parseDictionaryType(putSchema: DictionarySchema | undefined): JSONSchema4 { + const combinedSchema = throwIfNull(putSchema); + const additionalPropertiesType = parseType(combinedSchema.elementType, false); + + return { + type: 'object', + properties: {}, + additionalProperties: additionalPropertiesType + }; + } + + function parseArrayType(putSchema: ArraySchema | undefined): JSONSchema4 { + const itemType = parseType(putSchema?.elementType, false); + if (itemType === undefined) { + return { + type: 'array' + }; + } + + return { + type: 'array', + items: itemType, + }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function sortObjByKey(value: any): any { + return (typeof value === 'object') ? + (Array.isArray(value) ? + value.map(sortObjByKey) : + Object.keys(value).sort().reduce( + (o, key) => { + const v = value[key]; + o[key] = sortObjByKey(v); + return o; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }, {} as any) + ) : + value; + } + + function generateSchema(): JSONSchema4 { + const { resourcesByType } = definition; + + const generated: Dictionary = {}; + // order by length to ensure parent resources are processed before children + for (const fullyQualifiedType of orderBy(keys(resourcesByType), x => x.length)) { + const definitions = resourcesByType[fullyQualifiedType]; + + const output = processResource(fullyQualifiedType, definitions, false); + if (!output) { + continue; + } + + const { descriptor, schema } = output; + generated[fullyQualifiedType.toLowerCase()] = schema; + const definitionName = descriptor.typeSegments.join('_'); + + if (ScopeType.Tenant === (descriptor.scopeType & ScopeType.Tenant)) { + schemaData.tenantResources[definitionName] = schema; + } + if (ScopeType.ManagementGroup === (descriptor.scopeType & ScopeType.ManagementGroup)) { + schemaData.mgResources[definitionName] = schema; + } + if (ScopeType.Subscription === (descriptor.scopeType & ScopeType.Subscription)) { + schemaData.subResources[definitionName] = schema; + } + if (ScopeType.ResourceGroup === (descriptor.scopeType & ScopeType.ResourceGroup)) { + schemaData.rgResources[definitionName] = schema; + } + if (ScopeType.Extension === (descriptor.scopeType & ScopeType.Extension)) { + schemaData.extensionResources[definitionName] = schema; + } + if (descriptor.scopeType === ScopeType.Unknown) { + schemaData.unknownResources[definitionName] = schema; + } + + if (descriptor.typeSegments.length > 1) { + const parentType = [descriptor.namespace, ...descriptor.typeSegments.slice(0, -1)].join('/'); + const parentSchema = generated[parentType.toLowerCase()]; + + if (parentSchema) { + // generate nested resource schema definitions + const output = processResource(fullyQualifiedType, definitions, true); + if (!output) { + continue; + } + + const { schema } = output; + const childResourceDefinitionName = `${definitionName}_childResource`; + schemaData.definitions[childResourceDefinitionName] = schema; + + parentSchema.properties!['resources'] ??= { + type: 'array', + items: { + oneOf: [], + }, + }; + + const items = parentSchema.properties!['resources'].items as JSONSchema4; + items.oneOf = [...(items.oneOf || []), { '$ref': `#/definitions/${childResourceDefinitionName}` }] + } + } + } + + return { + id: `https://schema.management.azure.com/schemas/${definition.apiVersion}/${definition.namespace}.json#`, + title: definition.namespace, + description: `${definition.namespace.replace('.', ' ')} Resource Types`, + '$schema': "http://json-schema.org/draft-04/schema#", + resourceDefinitions: isEmpty(schemaData.rgResources) ? undefined : sortObjByKey(schemaData.rgResources), + subscription_resourceDefinitions: isEmpty(schemaData.subResources) ? undefined : sortObjByKey(schemaData.subResources), + managementGroup_resourceDefinitions: isEmpty(schemaData.mgResources) ? undefined : sortObjByKey(schemaData.mgResources), + tenant_resourceDefinitions: isEmpty(schemaData.tenantResources) ? undefined : sortObjByKey(schemaData.tenantResources), + extension_resourceDefinitions: isEmpty(schemaData.extensionResources) ? undefined : sortObjByKey(schemaData.extensionResources), + unknown_resourceDefinitions: isEmpty(schemaData.unknownResources) ? undefined : sortObjByKey(schemaData.unknownResources), + definitions: sortObjByKey(schemaData.definitions), + }; + } + + return generateSchema(); +} + +function isReadOnly(property: Property | undefined) { + const mutability = property?.extensions?.["x-ms-mutability"] as string[]; + + if (mutability && !(mutability.includes('create') || mutability.includes('update'))) { + return true; + } + + if (property?.readOnly === true) { + return true; + } + + return false; +} \ No newline at end of file diff --git a/src/autorest.bicep/test/integration/generated/arm-schema/basic/test.rp1/2021-10-31/schema.json b/src/autorest.bicep/test/integration/generated/arm-schema/basic/test.rp1/2021-10-31/schema.json new file mode 100644 index 0000000000..d01438c522 --- /dev/null +++ b/src/autorest.bicep/test/integration/generated/arm-schema/basic/test.rp1/2021-10-31/schema.json @@ -0,0 +1,558 @@ +{ + "id": "https://schema.management.azure.com/schemas/2021-10-31/Test.Rp1.json#", + "title": "Test.Rp1", + "description": "Test Rp1 Resource Types", + "$schema": "http://json-schema.org/draft-04/schema#", + "resourceDefinitions": { + "discriminatedUnionTestType": { + "description": "Test.Rp1/discriminatedUnionTestType", + "properties": { + "apiVersion": { + "enum": [ + "2021-10-31" + ], + "type": "string" + }, + "bar": { + "description": "The bar property", + "type": "string" + }, + "foo": { + "description": "The foo property", + "type": "string" + }, + "name": { + "description": "The discriminatedUnionTestType resource name.", + "type": "string" + }, + "type": { + "enum": [ + "Test.Rp1/discriminatedUnionTestType" + ], + "type": "string" + } + }, + "required": [ + "name", + "apiVersion", + "type" + ], + "type": "object" + }, + "partlyReadonlyType": { + "description": "Test.Rp1/partlyReadonlyType", + "properties": { + "apiVersion": { + "enum": [ + "2021-10-31" + ], + "type": "string" + }, + "location": { + "description": "The geo-location where the resource lives", + "type": "string" + }, + "name": { + "description": "The partlyReadonlyType resource name.", + "type": "string" + }, + "properties": { + "description": "", + "oneOf": [ + { + "$ref": "#/definitions/TestType1Properties" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "tags": { + "description": "Resource tags.", + "oneOf": [ + { + "additionalProperties": { + "type": "string" + }, + "properties": {}, + "type": "object" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "type": { + "enum": [ + "Test.Rp1/partlyReadonlyType" + ], + "type": "string" + } + }, + "required": [ + "name", + "properties", + "apiVersion", + "type" + ], + "type": "object" + }, + "testType1": { + "description": "Test.Rp1/testType1", + "properties": { + "apiVersion": { + "enum": [ + "2021-10-31" + ], + "type": "string" + }, + "location": { + "description": "The geo-location where the resource lives", + "type": "string" + }, + "name": { + "description": "The testType1 resource name.", + "oneOf": [ + { + "maxLength": 12, + "minLength": 1, + "pattern": "^[a-z][a-z0-9]*$", + "type": "string" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "properties": { + "description": "The resource properties.", + "oneOf": [ + { + "$ref": "#/definitions/TestType1CreateOrUpdateProperties" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "tags": { + "description": "Resource tags.", + "oneOf": [ + { + "additionalProperties": { + "type": "string" + }, + "properties": {}, + "type": "object" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "type": { + "enum": [ + "Test.Rp1/testType1" + ], + "type": "string" + } + }, + "required": [ + "name", + "properties", + "apiVersion", + "type" + ], + "type": "object" + } + }, + "subscription_resourceDefinitions": { + "splitPutAndGetType": { + "description": "Test.Rp1/splitPutAndGetType", + "properties": { + "apiVersion": { + "enum": [ + "2021-10-31" + ], + "type": "string" + }, + "location": { + "description": "The geo-location where the resource lives", + "type": "string" + }, + "name": { + "oneOf": [ + { + "enum": [ + "constantName" + ], + "type": "string" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "properties": { + "description": "The resource properties.", + "oneOf": [ + { + "$ref": "#/definitions/TestType1CreateOrUpdateProperties" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "tags": { + "description": "Resource tags.", + "oneOf": [ + { + "additionalProperties": { + "type": "string" + }, + "properties": {}, + "type": "object" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "type": { + "enum": [ + "Test.Rp1/splitPutAndGetType" + ], + "type": "string" + } + }, + "required": [ + "name", + "properties", + "apiVersion", + "type" + ], + "type": "object" + } + }, + "tenant_resourceDefinitions": { + "partlyReadonlyType": { + "description": "Test.Rp1/partlyReadonlyType", + "properties": { + "apiVersion": { + "enum": [ + "2021-10-31" + ], + "type": "string" + }, + "location": { + "description": "The geo-location where the resource lives", + "type": "string" + }, + "name": { + "description": "The partlyReadonlyType resource name.", + "type": "string" + }, + "properties": { + "description": "", + "oneOf": [ + { + "$ref": "#/definitions/TestType1Properties" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "tags": { + "description": "Resource tags.", + "oneOf": [ + { + "additionalProperties": { + "type": "string" + }, + "properties": {}, + "type": "object" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "type": { + "enum": [ + "Test.Rp1/partlyReadonlyType" + ], + "type": "string" + } + }, + "required": [ + "name", + "properties", + "apiVersion", + "type" + ], + "type": "object" + } + }, + "definitions": { + "EncryptionProperties": { + "description": "Configuration of key for data encryption", + "properties": { + "keyVaultProperties": { + "description": "Key vault properties.", + "oneOf": [ + { + "$ref": "#/definitions/KeyVaultProperties" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "status": { + "description": "Indicates whether or not the encryption is enabled for container registry.", + "oneOf": [ + { + "enum": [ + "enabled", + "disabled" + ], + "type": "string" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + } + }, + "type": "object" + }, + "KeyVaultProperties": { + "properties": { + "identity": { + "description": "The client ID of the identity which will be used to access key vault.", + "type": "string" + }, + "keyIdentifier": { + "description": "Key vault uri to access the encryption key.", + "type": "string" + } + }, + "type": "object" + }, + "LocationData": { + "description": "Metadata pertaining to the geographic location of the resource.", + "properties": { + "city": { + "description": "The city or locality where the resource is located.", + "type": "string" + }, + "countryOrRegion": { + "description": "The country or region where the resource is located", + "type": "string" + }, + "district": { + "description": "The district, state, or province where the resource is located.", + "type": "string" + }, + "name": { + "description": "A canonical name for the geographic or physical location.", + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "TestType1CreateOrUpdateProperties": { + "properties": { + "basicString": { + "description": "Description for a basic string property.", + "type": "string" + }, + "encryptionProperties": { + "description": "TestType1 encryption properties", + "oneOf": [ + { + "$ref": "#/definitions/EncryptionProperties" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "password": { + "description": "", + "oneOf": [ + { + "pattern": "^[a-zA-Z0-9\\.]$", + "type": "string" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "skuTier": { + "description": "This field is required to be implemented by the Resource Provider if the service has more than one tier, but is not required on a PUT.", + "oneOf": [ + { + "enum": [ + "Free", + "Basic", + "Standard", + "Premium" + ], + "type": "string" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "stringEnum": { + "description": "Description for a basic enum property.", + "oneOf": [ + { + "enum": [ + "Foo", + "Bar" + ], + "type": "string" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "uuidProperty": { + "description": "", + "oneOf": [ + { + "pattern": "^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + } + }, + "type": "object" + }, + "TestType1Properties": { + "properties": { + "base64EncodedBytes": { + "description": "", + "type": "string" + }, + "basicString": { + "description": "Description for a basic string property.", + "type": "string" + }, + "binaryBuffer": { + "description": "" + }, + "encryptionProperties": { + "description": "TestType1 encryption properties", + "oneOf": [ + { + "$ref": "#/definitions/EncryptionProperties" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "locationData": { + "description": "Metadata pertaining to the geographic location of the resource.", + "oneOf": [ + { + "$ref": "#/definitions/LocationData" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "password": { + "description": "", + "oneOf": [ + { + "pattern": "^[a-zA-Z0-9\\.]$", + "type": "string" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "percentageProperty": { + "description": "", + "oneOf": [ + { + "maximum": 100, + "minimum": 0, + "type": "integer" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "skuTier": { + "description": "This field is required to be implemented by the Resource Provider if the service has more than one tier, but is not required on a PUT.", + "oneOf": [ + { + "enum": [ + "Free", + "Basic", + "Standard", + "Premium" + ], + "type": "string" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "stringEnum": { + "description": "Description for a basic enum property.", + "oneOf": [ + { + "enum": [ + "Foo", + "Bar" + ], + "type": "string" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + }, + "subnetId": { + "description": "A fully-qualified resource ID", + "type": "string" + }, + "uuidProperty": { + "description": "", + "oneOf": [ + { + "pattern": "^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + { + "$ref": "https://schema.management.azure.com/schemas/common/definitions.json#/definitions/expression" + } + ] + } + }, + "type": "object" + } + } +} \ No newline at end of file diff --git a/src/autorest.bicep/test/integration/generated/basic/test.rp1/2021-10-31/types.json b/src/autorest.bicep/test/integration/generated/bicep/basic/test.rp1/2021-10-31/types.json similarity index 100% rename from src/autorest.bicep/test/integration/generated/basic/test.rp1/2021-10-31/types.json rename to src/autorest.bicep/test/integration/generated/bicep/basic/test.rp1/2021-10-31/types.json diff --git a/src/autorest.bicep/test/integration/generated/basic/test.rp1/2021-10-31/types.md b/src/autorest.bicep/test/integration/generated/bicep/basic/test.rp1/2021-10-31/types.md similarity index 100% rename from src/autorest.bicep/test/integration/generated/basic/test.rp1/2021-10-31/types.md rename to src/autorest.bicep/test/integration/generated/bicep/basic/test.rp1/2021-10-31/types.md diff --git a/src/autorest.bicep/test/integration/integration.test.ts b/src/autorest.bicep/test/integration/integration.test.ts index 22539e465f..c684164b8f 100644 --- a/src/autorest.bicep/test/integration/integration.test.ts +++ b/src/autorest.bicep/test/integration/integration.test.ts @@ -1,78 +1,44 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import os from 'os'; import path from 'path'; -import { rm, mkdir } from 'fs/promises'; +import { rm } from 'fs/promises'; import { compare } from 'dir-compare'; -import { defaultLogger, executeCmd, ILogger } from './utils'; +import { defaultLogger, isBaselineRecordEnabled, runAutorest } from './utils'; -const extensionDir = path.resolve(`${__dirname}/../../`); -const autorestBinary = os.platform() === 'win32' ? 'autorest.cmd' : 'autorest'; const outputBaseDir = `${__dirname}/generated`; -async function generateSchema(logger: ILogger, readme: string, outputBaseDir: string, verbose: boolean, waitForDebugger: boolean) { - let autoRestParams = [ - `--use=@autorest/modelerfour`, - `--use=${extensionDir}`, - '--bicep', - `--output-folder=${outputBaseDir}`, - `--multiapi`, - '--title=none', - // This is necessary to avoid failures such as "ERROR: Semantic violation: Discriminator must be a required property." blocking type generation. - // In an ideal world, we'd raise issues in https://github.com/Azure/azure-rest-api-specs and force RP teams to fix them, but this isn't very practical - // as new validations are added continuously, and there's often quite a lag before teams will fix them - we don't want to be blocked by this in generating types. - `--skip-semantics-validation`, - readme, - ]; - - if (verbose) { - autoRestParams = autoRestParams.concat([ - `--debug`, - `--verbose`, - ]); - } - - if (waitForDebugger) { - autoRestParams = autoRestParams.concat([ - `--bicep.debugger`, - ]); - } - - return await executeCmd(logger, verbose, __dirname, autorestBinary, autoRestParams); -} - -describe('integration tests', () => { - // add any new spec paths under ./specs to this list - const specs = [ - `basic`, - ] - - // set to true to overwrite baselines - const record = true; - - // bump timeout - autorest can take a while to run - jest.setTimeout(60000); - - for (const spec of specs) { - it(spec, async () => { - const readmePath = path.join(__dirname, `specs/${spec}/resource-manager/README.md`); - const outputDir = `${outputBaseDir}/${spec}`; - - if (record) { - await rm(outputDir, { recursive: true, force: true, }); - await generateSchema(defaultLogger, readmePath, outputDir, false, false); - } else { - const stagingOutputDir = `${__dirname}/temp/${spec}`; - await rm(stagingOutputDir, { recursive: true, force: true, }); - - await generateSchema(defaultLogger, readmePath, stagingOutputDir, false, false); - - const compareResult = await compare(stagingOutputDir, outputDir, { compareContent: true }); - - // Assert that the generated files match the baseline files which have been checked in. - // Set 'record' to true to run the tests in record mode and overwrite baselines. - expect(compareResult.differences).toBe(0); - } - }); - } -}); +// add any new spec paths under ./specs to this list +const specs = [ + `basic` +] + +for (const armSchema of [false, true]) { + describe(`integration tests (arm-schema: ${armSchema})`, () => { + // bump timeout - autorest can take a while to run + jest.setTimeout(60000); + + for (const spec of specs) { + it(spec, async () => { + const readmePath = path.join(__dirname, `specs/${spec}/resource-manager/README.md`); + const childDir = armSchema ? 'arm-schema' : 'bicep'; + const outputDir = `${outputBaseDir}/${childDir}/${spec}`; + + if (isBaselineRecordEnabled()) { + await rm(outputDir, { recursive: true, force: true, }); + await runAutorest(defaultLogger, readmePath, outputDir, armSchema, false, false); + } else { + const stagingOutputDir = `${__dirname}/temp/${spec}`; + await rm(stagingOutputDir, { recursive: true, force: true, }); + + await runAutorest(defaultLogger, readmePath, stagingOutputDir, armSchema, false, false); + + const compareResult = await compare(stagingOutputDir, outputDir, { compareContent: true }); + + // Assert that the generated files match the baseline files which have been checked in. + // Set 'record' to true to run the tests in record mode and overwrite baselines. + expect(compareResult.differences).toBe(0); + } + }); + } + }); +} \ No newline at end of file diff --git a/src/autorest.bicep/test/integration/utils.ts b/src/autorest.bicep/test/integration/utils.ts index efc20065d5..9e8cf40bc6 100644 --- a/src/autorest.bicep/test/integration/utils.ts +++ b/src/autorest.bicep/test/integration/utils.ts @@ -1,11 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import os from 'os'; import path from 'path'; import { createWriteStream } from 'fs'; import { readdir, stat, mkdir, rm, copyFile } from 'fs/promises'; import { spawn } from 'child_process'; import colors from 'colors'; +export const extensionDir = path.resolve(`${__dirname}/../../`); + export interface ILogger { out: (data: string) => void; err: (data: string) => void; @@ -120,4 +123,47 @@ export async function getLogger(logFilePath: string): Promise { logFileStream.write(colors.stripColors(data)); }, }; +} + +export async function runAutorest(logger: ILogger, readme: string, outputBaseDir: string, armSchema: boolean, verbose: boolean, waitForDebugger: boolean) { + let autoRestParams = [ + `--use=@autorest/modelerfour`, + `--use=${extensionDir}`, + '--bicep', + `--output-folder=${outputBaseDir}`, + `--multiapi`, + '--title=none', + // This is necessary to avoid failures such as "ERROR: Semantic violation: Discriminator must be a required property." blocking type generation. + // In an ideal world, we'd raise issues in https://github.com/Azure/azure-rest-api-specs and force RP teams to fix them, but this isn't very practical + // as new validations are added continuously, and there's often quite a lag before teams will fix them - we don't want to be blocked by this in generating types. + `--skip-semantics-validation`, + readme, + ]; + + if (armSchema) { + autoRestParams = autoRestParams.concat([ + `--arm-schema=true`, + ]); + } + + if (verbose) { + autoRestParams = autoRestParams.concat([ + `--debug`, + `--verbose`, + ]); + } + + if (waitForDebugger) { + autoRestParams = autoRestParams.concat([ + `--bicep.debugger`, + ]); + } + + const autorestBinary = os.platform() === 'win32' ? 'autorest.cmd' : 'autorest'; + return await executeCmd(logger, verbose, __dirname, autorestBinary, autoRestParams); +} + +export function isBaselineRecordEnabled() { + // set to true to overwrite baselines + return process.env['BASELINE_RECORD']?.toLowerCase() === 'true'; } \ No newline at end of file diff --git a/src/generator/src/cmd/generate.ts b/src/generator/src/cmd/generate.ts index baa6213a95..3954943f36 100644 --- a/src/generator/src/cmd/generate.ts +++ b/src/generator/src/cmd/generate.ts @@ -73,7 +73,7 @@ executeSynchronous(async () => { try { // autorest readme.bicep.md files are not checked in, so we must generate them before invoking autorest await generateAutorestConfig(logger, readmePath, bicepReadmePath, config); - await generateSchema(logger, readmePath, tmpOutputDir, logLevel, waitForDebugger); + await runAutorest(logger, readmePath, tmpOutputDir, logLevel, waitForDebugger); if (!subdirsCreated.has(outputDir)) { subdirsCreated.add(outputDir); @@ -106,6 +106,9 @@ ${err} // build the type index await buildTypeIndex(defaultLogger, outputBaseDir); + + // log if there are any type dirs with no corresponding readme (e.g. if a swagger directory has been removed). + await logStaleReadmes(defaultLogger, outputBaseDir, specsPath, readmePaths); }); function normalizeJsonPath(jsonPath: string) { @@ -189,7 +192,7 @@ ${yaml.dump({ 'input-file': filesByTag[tag] }, { lineWidth: 1000})} await writeFile(bicepReadmePath, generatedContent); } -async function generateSchema(logger: ILogger, readme: string, outputBaseDir: string, logLevel: string, waitForDebugger: boolean) { +async function runAutorest(logger: ILogger, readme: string, outputBaseDir: string, logLevel: string, waitForDebugger: boolean) { let autoRestParams = [ `--use=@autorest/modelerfour`, `--use=${extensionDir}`, @@ -240,6 +243,27 @@ async function findReadmePaths(specsPath: string) { }); } +async function logStaleReadmes(logger: ILogger, outputBaseDir: string, specsPath: string, readmePaths: string[]) { + const typesPaths = await findRecursive(outputBaseDir, filePath => { + return path.basename(filePath) === 'types.json'; + }); + + const typesBasePaths = typesPaths.map(p => path.relative(outputBaseDir, p).split(path.sep)[0].toLowerCase()); + const readmeBasePaths = readmePaths.map(p => path.relative(specsPath, p).split(path.sep)[0].toLowerCase()); + + const staleBasePaths = typesBasePaths + .filter(p => !readmeBasePaths.includes(p)) + .filter((value, index, array) => array.indexOf(value) === index); + + if (staleBasePaths.length > 0) { + logOut(logger, `Found the following type folders which have no corresponding readme: '${staleBasePaths.join("', '")}'. Cleaning them up.`); + } + + for (const basePath of staleBasePaths) { + await rm(`${outputBaseDir}/${basePath}`, { recursive: true, force: true, }); + } +} + async function buildTypeIndex(logger: ILogger, baseDir: string) { const typesPaths = await findRecursive(baseDir, filePath => { return path.basename(filePath) === 'types.json';