| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const yaml = require('js-yaml'); | |
| const HTTP_METHODS = new Set(['get', 'post', 'put', 'delete', 'patch', 'head', 'options', 'trace']); | |
| function check(condition, message) { | |
| if (!condition) { | |
| throw new Error(message); | |
| } | |
| } | |
| function decodePointerSegment(segment) { | |
| return segment.replace(/~1/g, '/').replace(/~0/g, '~'); | |
| } | |
| function validateCommon(fileName) { | |
| const specPath = path.resolve(__dirname, '..', 'static', fileName); | |
| const spec = yaml.load(fs.readFileSync(specPath, 'utf8')); | |
| function checkSpec(condition, message) { | |
| check(condition, `${fileName}: ${message}`); | |
| } | |
| function resolveLocalRef(ref) { | |
| checkSpec(ref.startsWith('#/'), `Only local OpenAPI references are supported, found: ${ref}`); | |
| return ref | |
| .slice(2) | |
| .split('/') | |
| .map(decodePointerSegment) | |
| .reduce((current, segment) => { | |
| checkSpec( | |
| current && Object.prototype.hasOwnProperty.call(current, segment), | |
| `Unresolved OpenAPI reference: ${ref}`, | |
| ); | |
| return current[segment]; | |
| }, spec); | |
| } | |
| function visit(value) { | |
| if (Array.isArray(value)) { | |
| value.forEach(visit); | |
| return; | |
| } | |
| if (!value || typeof value !== 'object') { | |
| return; | |
| } | |
| checkSpec( | |
| !Object.prototype.hasOwnProperty.call(value, 'nullable'), | |
| 'OpenAPI 3.1 schemas must not use nullable', | |
| ); | |
| if (typeof value.$ref === 'string') { | |
| resolveLocalRef(value.$ref); | |
| } | |
| Object.values(value).forEach(visit); | |
| } | |
| function schema(name) { | |
| const value = spec.components && spec.components.schemas && spec.components.schemas[name]; | |
| checkSpec(value, `Missing OpenAPI schema: ${name}`); | |
| return value; | |
| } | |
| function requireProperties(schemaName, names) { | |
| const properties = schema(schemaName).properties || {}; | |
| names.forEach((name) => { | |
| checkSpec(properties[name], `Schema ${schemaName} is missing property: ${name}`); | |
| }); | |
| return properties; | |
| } | |
| function requireRequiredProperties(schemaName, names) { | |
| const required = schema(schemaName).required || []; | |
| names.forEach((name) => { | |
| checkSpec(required.includes(name), `Schema ${schemaName} must require property: ${name}`); | |
| }); | |
| } | |
| function requireTypedIntegerProperties(schemaName, names) { | |
| const properties = requireProperties(schemaName, names); | |
| names.forEach((name) => { | |
| checkSpec( | |
| properties[name].type === 'integer' && properties[name].format === 'int64', | |
| `Schema ${schemaName}.${name} must be an int64 integer`, | |
| ); | |
| }); | |
| } | |
| function requireSchemaReference(schemaName, composition, referencedSchemaName) { | |
| const references = schema(schemaName)[composition] || []; | |
| const expected = `#/components/schemas/${referencedSchemaName}`; | |
| checkSpec( | |
| references.some((reference) => reference.$ref === expected), | |
| `Schema ${schemaName}.${composition} is missing reference: ${expected}`, | |
| ); | |
| } | |
| function validatePathParameters(resourcePath, pathItem, operation) { | |
| const templateNames = Array.from(resourcePath.matchAll(/\{([^}]+)\}/g), (match) => match[1]); | |
| const parameters = [...(pathItem.parameters || []), ...(operation.parameters || [])].map( | |
| (parameter) => (parameter.$ref ? resolveLocalRef(parameter.$ref) : parameter), | |
| ); | |
| const pathParameters = parameters.filter((parameter) => parameter.in === 'path'); | |
| templateNames.forEach((name) => { | |
| const parameter = pathParameters.find((candidate) => candidate.name === name); | |
| checkSpec(parameter, `Path ${resourcePath} is missing path parameter: ${name}`); | |
| checkSpec( | |
| parameter.required === true, | |
| `Path parameter ${resourcePath}.${name} must be required`, | |
| ); | |
| }); | |
| pathParameters.forEach((parameter) => { | |
| checkSpec( | |
| templateNames.includes(parameter.name), | |
| `Path ${resourcePath} declares unused path parameter: ${parameter.name}`, | |
| ); | |
| }); | |
| } | |
| checkSpec(spec.openapi === '3.1.1', `Expected OpenAPI 3.1.1, found: ${spec.openapi}`); | |
| checkSpec( | |
| spec.paths && spec.components && spec.components.schemas, | |
| 'Incomplete OpenAPI document', | |
| ); | |
| visit(spec); | |
| const operations = new Map(); | |
| for (const [resourcePath, pathItem] of Object.entries(spec.paths)) { | |
| for (const [method, operation] of Object.entries(pathItem)) { | |
| if (!HTTP_METHODS.has(method)) { | |
| continue; | |
| } | |
| validatePathParameters(resourcePath, pathItem, operation); | |
| checkSpec( | |
| operation.operationId, | |
| `Operation ${method.toUpperCase()} ${resourcePath} has no operationId`, | |
| ); | |
| checkSpec( | |
| !operations.has(operation.operationId), | |
| `Duplicate operationId: ${operation.operationId}`, | |
| ); | |
| operations.set(operation.operationId, operation); | |
| } | |
| } | |
| function requireOperation(operationId) { | |
| const operation = operations.get(operationId); | |
| checkSpec(operation, `Missing operationId: ${operationId}`); | |
| return operation; | |
| } | |
| function requireResponses(operationId, statusCodes) { | |
| const responses = requireOperation(operationId).responses || {}; | |
| statusCodes.forEach((statusCode) => { | |
| checkSpec( | |
| responses[statusCode], | |
| `Operation ${operationId} is missing response: ${statusCode}`, | |
| ); | |
| }); | |
| } | |
| return { | |
| spec, | |
| operations, | |
| schema, | |
| requireOperation, | |
| requireProperties, | |
| requireRequiredProperties, | |
| requireTypedIntegerProperties, | |
| requireSchemaReference, | |
| requireResponses, | |
| checkSpec, | |
| }; | |
| } | |
| function requireArrayOfIdentifiers(contract, schemaName, propertyName) { | |
| const properties = contract.requireProperties(schemaName, [propertyName]); | |
| contract.checkSpec( | |
| properties[propertyName].type === 'array', | |
| `Schema ${schemaName}.${propertyName} must be an array`, | |
| ); | |
| contract.checkSpec( | |
| properties[propertyName].items && | |
| properties[propertyName].items.$ref === '#/components/schemas/Identifier', | |
| `Schema ${schemaName}.${propertyName} items must reference Identifier`, | |
| ); | |
| } | |
| function requireNullableStringProperty(contract, schemaName, propertyName) { | |
| const property = contract.requireProperties(schemaName, [propertyName])[propertyName]; | |
| contract.checkSpec( | |
| Array.isArray(property.type) && | |
| property.type.includes('string') && | |
| property.type.includes('null'), | |
| `Schema ${schemaName}.${propertyName} must accept string and null`, | |
| ); | |
| } | |
| function requireExactEnum(contract, schemaName, expectedValues) { | |
| const actualValues = contract.schema(schemaName).enum || []; | |
| contract.checkSpec( | |
| actualValues.length === expectedValues.length && | |
| expectedValues.every((value) => actualValues.includes(value)), | |
| `Schema ${schemaName} must define enum values: ${expectedValues.join(', ')}`, | |
| ); | |
| } | |
| function validateCatalogOpenApi() { | |
| const contract = validateCommon('rest-catalog-open-api.yaml'); | |
| [ | |
| 'getConfig', | |
| 'createDatabase', | |
| 'getDatabase', | |
| 'alterDatabase', | |
| 'dropDatabase', | |
| 'createTable', | |
| 'getTable', | |
| 'alterTable', | |
| 'dropTable', | |
| ].forEach(contract.requireOperation); | |
| const createOperation = contract.requireOperation('createPartitions'); | |
| const createRequestRef = | |
| createOperation.requestBody.content['application/json'].schema.$ref; | |
| contract.checkSpec( | |
| createRequestRef === '#/components/schemas/CreatePartitionsRequest', | |
| 'createPartitions must use CreatePartitionsRequest', | |
| ); | |
| contract.checkSpec( | |
| !contract.operations.has('createPartitionsWithOptions'), | |
| 'partition options must use the existing createPartitions operation', | |
| ); | |
| const createResponseRef = | |
| createOperation.responses['200'].content['application/json'].schema.$ref; | |
| contract.checkSpec( | |
| createResponseRef === '#/components/schemas/CreatePartitionsResponse', | |
| 'createPartitions must use CreatePartitionsResponse', | |
| ); | |
| contract.checkSpec( | |
| createOperation.responses['400'] && createOperation.responses['501'], | |
| 'createPartitions must declare invalid and unsupported option responses', | |
| ); | |
| const createRequestProperties = contract.requireProperties('CreatePartitionsRequest', [ | |
| 'partitionSpecs', | |
| 'partitionOptions', | |
| ]); | |
| const requestOptions = createRequestProperties.partitionOptions; | |
| contract.checkSpec( | |
| Array.isArray(requestOptions.type) && | |
| requestOptions.type.includes('array') && | |
| requestOptions.type.includes('null') && | |
| requestOptions.items.type === 'object' && | |
| requestOptions.items.additionalProperties.type === 'string', | |
| 'CreatePartitionsRequest.partitionOptions must be a nullable array of string maps', | |
| ); | |
| contract.checkSpec( | |
| requestOptions.description.includes('partitionSpecs') && | |
| requestOptions.description.toLowerCase().includes('position') && | |
| requestOptions.description.toLowerCase().includes('same length') && | |
| requestOptions.description.toLowerCase().includes('empty object') && | |
| requestOptions.description.includes('replaceStatistics=true') && | |
| requestOptions.description.includes('partitionStatistics') && | |
| requestOptions.description.toLowerCase().includes('default directory'), | |
| 'CreatePartitionsRequest.partitionOptions must document alignment and returns to the default directory', | |
| ); | |
| contract.requireProperties('Partition', ['options']); | |
| contract.requireProperties('ConfigResponse', ['defaults', 'overrides']); | |
| contract.requireProperties('CreateDatabaseRequest', ['name', 'options']); | |
| contract.requireProperties('AlterDatabaseRequest', ['removals', 'updates']); | |
| contract.requireProperties('CreateTableRequest', ['identifier', 'schema']); | |
| contract.requireProperties('AlterTableRequest', ['changes']); | |
| contract.requireProperties('Identifier', ['database', 'object']); | |
| contract.requireProperties('Schema', [ | |
| 'fields', | |
| 'partitionKeys', | |
| 'primaryKeys', | |
| 'options', | |
| 'comment', | |
| ]); | |
| contract.requireProperties('DataField', ['id', 'name', 'type', 'description', 'defaultValue']); | |
| contract.requireSchemaReference('DataType', 'oneOf', 'VectorType'); | |
| contract.requireProperties('VectorType', ['type', 'element', 'length']); | |
| contract.requireSchemaReference('SchemaChange', 'anyOf', 'DropPrimaryKey'); | |
| contract.checkSpec( | |
| contract.schema('BaseSchemaChange').discriminator.mapping.dropPrimaryKey === | |
| '#/components/schemas/DropPrimaryKey', | |
| 'BaseSchemaChange discriminator is missing dropPrimaryKey', | |
| ); | |
| const dropPrimaryKey = contract.requireProperties('DropPrimaryKey', ['action']); | |
| contract.checkSpec( | |
| dropPrimaryKey.action.const === 'dropPrimaryKey', | |
| 'Schema DropPrimaryKey.action must be dropPrimaryKey', | |
| ); | |
| contract.checkSpec( | |
| contract.schema('BaseInstant').discriminator.propertyName === 'type', | |
| 'BaseInstant discriminator must use the JSON field type', | |
| ); | |
| const updateViewComment = contract.requireProperties('UpdateViewComment', ['action', 'comment']); | |
| contract.checkSpec( | |
| !updateViewComment.key, | |
| 'Schema UpdateViewComment must use comment instead of key', | |
| ); | |
| ['UpdateComment', 'UpdateViewComment', 'UpdateFunctionComment'].forEach((schemaName) => | |
| requireNullableStringProperty(contract, schemaName, 'comment'), | |
| ); | |
| const errorResourceTypes = | |
| contract.requireProperties('ErrorResponse', ['resourceType']).resourceType.enum || []; | |
| ['FUNCTION', 'DEFINITION'].forEach((resourceType) => { | |
| contract.checkSpec( | |
| errorResourceTypes.includes(resourceType), | |
| `Schema ErrorResponse.resourceType is missing value: ${resourceType}`, | |
| ); | |
| }); | |
| requireArrayOfIdentifiers(contract, 'ListTablesGloballyResponse', 'tables'); | |
| requireArrayOfIdentifiers(contract, 'ListViewsGloballyResponse', 'views'); | |
| requireArrayOfIdentifiers(contract, 'ListFunctionsGloballyResponse', 'functions'); | |
| contract.requireProperties('ListFunctionsGloballyResponse', ['nextPageToken']); | |
| const getFunctionProperties = contract.requireProperties('GetFunctionResponse', ['uuid']); | |
| contract.checkSpec( | |
| getFunctionProperties.uuid.type === 'string', | |
| 'Schema GetFunctionResponse.uuid must be a string', | |
| ); | |
| ['GetDatabaseResponse', 'GetTableResponse', 'GetViewResponse', 'GetFunctionResponse'].forEach( | |
| (schemaName) => contract.requireTypedIntegerProperties(schemaName, ['createdAt', 'updatedAt']), | |
| ); | |
| const semanticCollection = '/v1/{prefix}/databases/{database}/semantic-views'; | |
| const semanticItem = `${semanticCollection}/{semanticView}`; | |
| [ | |
| [semanticCollection, 'get', 'listSemanticViews'], | |
| [semanticItem, 'get', 'getSemanticView'], | |
| [semanticItem, 'post', 'upsertSemanticView'], | |
| [semanticItem, 'delete', 'deleteSemanticView'], | |
| ].forEach(([resourcePath, method, operationId]) => { | |
| contract.checkSpec( | |
| contract.spec.paths[resourcePath]?.[method]?.operationId === operationId, | |
| `${operationId} must use ${method.toUpperCase()} ${resourcePath}`, | |
| ); | |
| contract.requireResponses(operationId, ['200', '400', '401', '403', '404', '500', '501']); | |
| }); | |
| contract.checkSpec(!contract.spec.paths[semanticItem].patch, 'Semantic views use POST replacement'); | |
| contract.requireResponses('upsertSemanticView', ['409', '413']); | |
| contract.requireResponses('deleteSemanticView', ['409']); | |
| const semanticUpsert = contract.requireOperation('upsertSemanticView'); | |
| contract.checkSpec( | |
| semanticUpsert.requestBody.required && | |
| semanticUpsert.requestBody.content['application/json'].schema.$ref === | |
| '#/components/schemas/UpsertSemanticViewRequest' && | |
| semanticUpsert.responses['200'].content['application/json'].schema.$ref === | |
| '#/components/schemas/GetSemanticViewResponse', | |
| 'Semantic view upsert must take a complete definition and return the committed object', | |
| ); | |
| const semanticDelete = contract.requireOperation('deleteSemanticView'); | |
| contract.checkSpec( | |
| !semanticDelete.requestBody && | |
| !(semanticDelete.parameters || []).some((parameter) => parameter.in === 'query'), | |
| 'Semantic view DELETE must use only the resource path without query parameters or a body', | |
| ); | |
| const pageSize = contract.requireOperation('listSemanticViews').parameters.find( | |
| (parameter) => parameter.name === 'maxResults', | |
| ); | |
| contract.checkSpec( | |
| pageSize.schema.minimum === 1 && pageSize.schema.maximum === 1000, | |
| 'Semantic view page size must be between 1 and 1000', | |
| ); | |
| contract.requireRequiredProperties('SemanticViewDefinition', ['format', 'content']); | |
| const definition = contract.requireProperties('SemanticViewDefinition', ['format', 'content']); | |
| contract.checkSpec( | |
| Object.keys(definition).length === 2 && !definition.format.enum && | |
| definition.content['x-max-utf8-bytes'] === 1048576, | |
| 'Semantic definitions require only an extensible format and content with a 1 MiB UTF-8 limit', | |
| ); | |
| contract.requireRequiredProperties('UpsertSemanticViewRequest', ['definition']); | |
| contract.requireRequiredProperties('GetSemanticViewResponse', ['name', 'definition']); | |
| contract.checkSpec( | |
| Object.keys(contract.requireProperties('UpsertSemanticViewRequest', ['definition'])).length === 1 && | |
| Object.keys(contract.requireProperties('GetSemanticViewResponse', ['name', 'definition'])).length === 2, | |
| 'Semantic upserts contain only a definition; responses contain only a name and definition', | |
| ); | |
| const semanticNames = contract.requireProperties('ListSemanticViewsResponse', ['semanticViews', 'nextPageToken']); | |
| contract.checkSpec( | |
| semanticNames.semanticViews.type === 'array' && semanticNames.semanticViews.items.type === 'string', | |
| 'Semantic view lists must contain names only', | |
| ); | |
| contract.checkSpec( | |
| errorResourceTypes.includes('SEMANTIC_VIEW'), | |
| 'Semantic view errors must identify SEMANTIC_VIEW', | |
| ); | |
| contract.checkSpec( | |
| contract.requireOperation('alterView').requestBody.content['application/json'].schema.$ref === | |
| '#/components/schemas/AlterViewRequest', | |
| 'Ordinary view POST must retain its existing alteration request', | |
| ); | |
| return contract.operations.size; | |
| } | |
| function validateManagementOpenApi() { | |
| const contract = validateCommon('rest-management-open-api.yaml'); | |
| const operationIds = [ | |
| 'upsertLabel', | |
| 'listLabels', | |
| 'getLabel', | |
| 'deleteLabel', | |
| 'listPermissions', | |
| 'grantPermission', | |
| 'revokePermission', | |
| 'listTablePolicies', | |
| 'createTablePolicy', | |
| 'dropTablePolicy', | |
| ]; | |
| const resourcePaths = [ | |
| '/v1/{prefix}/labels/{entityType}/{entityName}', | |
| '/v1/{prefix}/labels/{entityType}/{entityName}/{key}', | |
| '/v1/{prefix}/permissions', | |
| '/v1/{prefix}/permissions/grant', | |
| '/v1/{prefix}/permissions/revoke', | |
| '/v1/{prefix}/databases/{database}/tables/{table}/policies', | |
| '/v1/{prefix}/databases/{database}/tables/{table}/policies/drop', | |
| ]; | |
| resourcePaths.forEach((resourcePath) => | |
| contract.checkSpec( | |
| contract.spec.paths[resourcePath], | |
| `Missing management path: ${resourcePath}`, | |
| ), | |
| ); | |
| [ | |
| '/v1/{prefix}/policies', | |
| '/v1/{prefix}/databases/{database}/policies', | |
| '/v1/{prefix}/databases/{database}/tables/{table}/policies/{policyName}', | |
| ].forEach((resourcePath) => | |
| contract.checkSpec( | |
| !contract.spec.paths[resourcePath], | |
| `Policies must not be attachable outside tables: ${resourcePath}`, | |
| ), | |
| ); | |
| operationIds.forEach(contract.requireOperation); | |
| ['upsertLabel', 'listLabels', 'getLabel', 'deleteLabel'].forEach((operationId) => | |
| contract.requireResponses(operationId, ['200', '400', '401', '403', '404', '429', '500', '503']), | |
| ); | |
| contract.requireRequiredProperties('UpsertLabelRequest', ['value']); | |
| const labelFields = contract.requireProperties('UpsertLabelRequest', ['value']); | |
| contract.checkSpec( | |
| Object.keys(labelFields).length === 1 && | |
| labelFields.value.type === 'string' && | |
| !labelFields.value.minLength, | |
| 'Label writes must contain only a string value, permitting empty strings but rejecting null', | |
| ); | |
| contract.checkSpec( | |
| contract.schema('LabelEntityType').type === 'string' && !contract.schema('LabelEntityType').enum, | |
| 'Label entity types must remain extensible strings', | |
| ); | |
| const labelFieldNames = ['entityType', 'entityName', 'key', 'value']; | |
| contract.requireRequiredProperties('GetLabelResponse', labelFieldNames); | |
| contract.requireProperties('GetLabelResponse', labelFieldNames); | |
| contract.requireRequiredProperties('ListLabelsResponse', ['labels']); | |
| const labelList = contract.requireProperties('ListLabelsResponse', ['labels', 'nextPageToken']); | |
| contract.checkSpec( | |
| labelList.labels.items.$ref === '#/components/schemas/GetLabelResponse', | |
| 'Label listings must return complete bindings', | |
| ); | |
| const labelRoot = contract.spec.paths['/v1/{prefix}/labels']; | |
| const labelEntity = contract.spec.paths['/v1/{prefix}/labels/{entityType}/{entityName}']; | |
| const labelKey = contract.spec.paths['/v1/{prefix}/labels/{entityType}/{entityName}/{key}']; | |
| contract.checkSpec( | |
| !labelRoot && | |
| labelEntity.get.operationId === 'listLabels' && | |
| labelKey.get.operationId === 'getLabel' && | |
| labelKey.post.operationId === 'upsertLabel' && | |
| labelKey.delete.operationId === 'deleteLabel' && | |
| !labelKey.patch && | |
| !labelKey.put, | |
| 'Labels must use POST/GET/DELETE on the single-key path with no collection-level write endpoint', | |
| ); | |
| ['listPermissions', 'grantPermission', 'revokePermission'].forEach((operationId) => | |
| contract.requireResponses(operationId, [ | |
| '200', | |
| '400', | |
| '401', | |
| '403', | |
| '404', | |
| '429', | |
| '500', | |
| '503', | |
| ]), | |
| ); | |
| contract.requireResponses('grantPermission', ['409']); | |
| ['listTablePolicies', 'createTablePolicy'].forEach((operationId) => | |
| contract.requireResponses(operationId, [ | |
| '200', | |
| '400', | |
| '401', | |
| '403', | |
| '404', | |
| '429', | |
| '500', | |
| '503', | |
| ]), | |
| ); | |
| contract.requireResponses('dropTablePolicy', [ | |
| '200', | |
| '400', | |
| '401', | |
| '403', | |
| '404', | |
| '429', | |
| '500', | |
| '503', | |
| ]); | |
| contract.requireResponses('createTablePolicy', ['409']); | |
| contract.checkSpec( | |
| contract.spec.info.version === '1.0' && | |
| contract.spec.info.description.toLowerCase().includes('experimental'), | |
| 'The management contract must be versioned 1.0 and marked experimental', | |
| ); | |
| contract.checkSpec( | |
| !Object.prototype.hasOwnProperty.call(contract.spec, 'security'), | |
| 'The management contract must not require one deployment-specific authentication scheme', | |
| ); | |
| const assignmentFields = ['resource', 'access', 'principal', 'columns', 'expireTime']; | |
| contract.requireProperties('PermissionAssignment', assignmentFields); | |
| contract.requireRequiredProperties('PermissionAssignment', ['resource', 'access', 'principal']); | |
| const grantProperties = contract.requireProperties('GrantPermissionRequest', [ | |
| 'resource', | |
| 'access', | |
| 'principal', | |
| 'columns', | |
| 'expireTime', | |
| ]); | |
| contract.requireRequiredProperties('GrantPermissionRequest', [ | |
| 'resource', | |
| 'access', | |
| 'principal', | |
| ]); | |
| ['policy', 'grantOption'].forEach((field) => | |
| contract.checkSpec( | |
| !grantProperties[field], | |
| `Schema GrantPermissionRequest must omit field: ${field}`, | |
| ), | |
| ); | |
| const revokeProperties = contract.requireProperties('RevokePermissionRequest', [ | |
| 'resource', | |
| 'access', | |
| 'principal', | |
| ]); | |
| contract.requireRequiredProperties('RevokePermissionRequest', [ | |
| 'resource', | |
| 'access', | |
| 'principal', | |
| ]); | |
| ['expireTime', 'columns', 'policy', 'grantOption'].forEach((field) => | |
| contract.checkSpec( | |
| !revokeProperties[field], | |
| `Schema RevokePermissionRequest must omit field: ${field}`, | |
| ), | |
| ); | |
| const permissionList = contract.requireProperties('ListPermissionsResponse', [ | |
| 'permissions', | |
| 'nextPageToken', | |
| ]); | |
| contract.checkSpec( | |
| permissionList.permissions.type === 'array' && | |
| permissionList.permissions.items.$ref === '#/components/schemas/PermissionAssignment', | |
| 'ListPermissionsResponse.permissions must contain PermissionAssignment values', | |
| ); | |
| contract.requireRequiredProperties('ListPermissionsResponse', ['permissions']); | |
| const principal = contract.schema('Principal'); | |
| contract.checkSpec( | |
| principal.type === 'string' && principal.minLength === 1 && principal.maxLength === 128, | |
| 'Principal must be a non-empty string that fits the 128-character persistence identity', | |
| ); | |
| contract.checkSpec( | |
| !contract.spec.components.schemas.PrincipalRef && | |
| !contract.spec.components.schemas.PrincipalType && | |
| !contract.spec.components.parameters.PrincipalTypeQuery, | |
| 'Principal must not expose a separate reference object or type', | |
| ); | |
| ['PermissionAssignment', 'GrantPermissionRequest', 'RevokePermissionRequest'].forEach( | |
| (schemaName) => | |
| contract.checkSpec( | |
| contract.requireProperties(schemaName, ['principal']).principal.$ref === | |
| '#/components/schemas/Principal', | |
| `Schema ${schemaName}.principal must reference Principal`, | |
| ), | |
| ); | |
| contract.checkSpec( | |
| contract.spec.components.parameters.PrincipalQuery.schema.$ref === | |
| '#/components/schemas/Principal', | |
| 'PrincipalQuery must reference Principal', | |
| ); | |
| requireExactEnum(contract, 'ResourceType', [ | |
| 'CATALOG', | |
| 'CATALOG_ALL', | |
| 'DATABASE', | |
| 'DATABASE_ALL', | |
| 'TABLE', | |
| 'COLUMN', | |
| 'FUNCTION', | |
| 'VIEW', | |
| ]); | |
| requireExactEnum(contract, 'PolicyType', ['ROW_FILTER', 'COLUMN_MASKING']); | |
| Object.entries({ | |
| CatalogResource: 'CATALOG', | |
| CatalogAllResource: 'CATALOG_ALL', | |
| DatabaseResource: 'DATABASE', | |
| DatabaseAllResource: 'DATABASE_ALL', | |
| TableResource: 'TABLE', | |
| ColumnResource: 'COLUMN', | |
| FunctionResource: 'FUNCTION', | |
| ViewResource: 'VIEW', | |
| RowFilterPolicyIdentity: 'ROW_FILTER', | |
| ColumnMaskPolicyIdentity: 'COLUMN_MASKING', | |
| TablePolicyResource: 'TABLE', | |
| }).forEach(([schemaName, expectedType]) => { | |
| const typeProperty = contract.requireProperties(schemaName, ['type']).type; | |
| contract.checkSpec( | |
| typeProperty.type === 'string' && typeProperty.const === expectedType, | |
| `${schemaName}.type must be a typed string constant`, | |
| ); | |
| }); | |
| const permissionAccess = contract.schema('PermissionAccess'); | |
| const expectedAccesses = [ | |
| 'ALL', | |
| 'CREATEDATABASE', | |
| 'DESCRIBE', | |
| 'ALTER', | |
| 'DROP', | |
| 'CREATETABLE', | |
| 'CREATEFUNCTION', | |
| 'CREATEVIEW', | |
| 'LIST', | |
| 'SELECT', | |
| 'UPDATE', | |
| 'GRANT', | |
| ]; | |
| contract.checkSpec( | |
| permissionAccess.type === 'string' && | |
| permissionAccess.enum.length === expectedAccesses.length && | |
| expectedAccesses.every((access) => permissionAccess.enum.includes(access)), | |
| 'PermissionAccess must define the complete data access enum', | |
| ); | |
| contract.checkSpec( | |
| permissionAccess.maxLength === 32, | |
| 'PermissionAccess must fit the 32-character persistence field', | |
| ); | |
| ['PermissionAssignment', 'GrantPermissionRequest', 'RevokePermissionRequest'].forEach( | |
| (schemaName) => | |
| contract.checkSpec( | |
| contract.requireProperties(schemaName, ['access']).access.$ref === | |
| '#/components/schemas/PermissionAccess', | |
| `Schema ${schemaName}.access must reference PermissionAccess`, | |
| ), | |
| ); | |
| contract.checkSpec( | |
| contract.spec.components.parameters.AccessQuery.schema.$ref === | |
| '#/components/schemas/PermissionAccess', | |
| 'AccessQuery must reference PermissionAccess', | |
| ); | |
| contract.requireSchemaReference('PermissionResource', 'oneOf', 'ColumnResource'); | |
| contract.requireSchemaReference('PermissionResource', 'oneOf', 'CatalogAllResource'); | |
| contract.requireSchemaReference('PermissionResource', 'oneOf', 'DatabaseAllResource'); | |
| const catalogAllResource = contract.requireProperties('CatalogAllResource', ['type']); | |
| contract.requireRequiredProperties('CatalogAllResource', ['type']); | |
| contract.checkSpec( | |
| catalogAllResource.type.const === 'CATALOG_ALL', | |
| 'CatalogAllResource must use the CATALOG_ALL discriminator', | |
| ); | |
| const databaseAllResource = contract.requireProperties('DatabaseAllResource', [ | |
| 'type', | |
| 'database', | |
| ]); | |
| contract.requireRequiredProperties('DatabaseAllResource', ['type', 'database']); | |
| contract.checkSpec( | |
| databaseAllResource.type.const === 'DATABASE_ALL', | |
| 'DatabaseAllResource must use the DATABASE_ALL discriminator', | |
| ); | |
| const columnResource = contract.requireProperties('ColumnResource', [ | |
| 'type', | |
| 'database', | |
| 'table', | |
| ]); | |
| contract.requireRequiredProperties('ColumnResource', ['type', 'database', 'table']); | |
| contract.checkSpec( | |
| columnResource.type.const === 'COLUMN', | |
| 'ColumnResource must use the COLUMN discriminator', | |
| ); | |
| const permissionColumns = contract.requireProperties('PermissionColumns', [ | |
| 'columnNames', | |
| 'excludedColumnNames', | |
| ]); | |
| const permissionColumnsDescription = contract | |
| .schema('PermissionColumns') | |
| .description.toLowerCase() | |
| .replace(/\s+/g, ' '); | |
| ['intersected', 'fails the query', 'query authorization'].forEach((phrase) => | |
| contract.checkSpec( | |
| permissionColumnsDescription.includes(phrase), | |
| `PermissionColumns semantics are missing: ${phrase}`, | |
| ), | |
| ); | |
| ['columnNames', 'excludedColumnNames'].forEach((field) => { | |
| const definition = permissionColumns[field]; | |
| contract.checkSpec( | |
| definition.type === 'array' && | |
| definition.minItems === 1 && | |
| definition.uniqueItems === true && | |
| definition.items.type === 'string' && | |
| definition.items.minLength === 1, | |
| `PermissionColumns.${field} must be a non-empty unique string array`, | |
| ); | |
| }); | |
| const columnAlternatives = contract.schema('PermissionColumns').oneOf || []; | |
| ['columnNames', 'excludedColumnNames'].forEach((field) => | |
| contract.checkSpec( | |
| columnAlternatives.some( | |
| (alternative) => | |
| alternative.required && | |
| alternative.required.length === 1 && | |
| alternative.required[0] === field, | |
| ), | |
| `PermissionColumns must define the ${field} alternative`, | |
| ), | |
| ); | |
| ['PermissionAssignment', 'GrantPermissionRequest'].forEach((schemaName) => { | |
| const properties = contract.requireProperties(schemaName, ['columns']); | |
| contract.checkSpec( | |
| properties.columns.$ref === '#/components/schemas/PermissionColumns', | |
| `${schemaName}.columns must reference PermissionColumns`, | |
| ); | |
| contract.requireSchemaReference(schemaName, 'allOf', 'ColumnAssignmentConstraint'); | |
| }); | |
| ['DatabaseQuery', 'TableQuery', 'FunctionQuery', 'ViewQuery'].forEach((parameterName) => { | |
| const locatorQuery = contract.spec.components.parameters[parameterName]; | |
| contract.checkSpec( | |
| locatorQuery.schema.type === 'string' && locatorQuery.schema.minLength === 1, | |
| `${parameterName} must be a non-empty string`, | |
| ); | |
| }); | |
| ['TooManyRequests', 'ServiceUnavailable'].forEach((responseName) => { | |
| const response = contract.spec.components.responses[responseName]; | |
| contract.checkSpec(response, `Missing reusable response: ${responseName}`); | |
| contract.checkSpec( | |
| response.headers['Retry-After'].$ref === '#/components/headers/RetryAfter', | |
| `Response ${responseName} must expose the optional Retry-After header`, | |
| ); | |
| }); | |
| contract.checkSpec( | |
| contract.spec.components.headers.RetryAfter.schema.type === 'string', | |
| 'RetryAfter must allow HTTP delta-seconds or an HTTP date as a string', | |
| ); | |
| const expireTime = contract.schema('ExpireTime'); | |
| contract.checkSpec( | |
| expireTime.type === 'string' && | |
| expireTime.format === 'date-time' && | |
| expireTime.pattern === '^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(?:\\.\\d{1,3})?Z$', | |
| 'ExpireTime must use UTC Z with at most three fractional digits', | |
| ); | |
| ['PermissionAssignment', 'GrantPermissionRequest'].forEach((schemaName) => { | |
| const expiry = contract.requireProperties(schemaName, ['expireTime']).expireTime; | |
| contract.checkSpec( | |
| expiry.$ref === '#/components/schemas/ExpireTime', | |
| `${schemaName}.expireTime must reference the shared ExpireTime schema`, | |
| ); | |
| }); | |
| const expireTimePattern = new RegExp(expireTime.pattern); | |
| ['2027-01-01T00:00:00Z', '2027-01-01T00:00:00.1Z', '2027-01-01T00:00:00.123Z'].forEach( | |
| (value) => | |
| contract.checkSpec(expireTimePattern.test(value), `ExpireTime must accept ${value}`), | |
| ); | |
| ['2027-01-01T00:00:00.123456Z', '2027-01-01T00:00:00+00:00'].forEach((value) => | |
| contract.checkSpec(!expireTimePattern.test(value), `ExpireTime must reject ${value}`), | |
| ); | |
| const expiryDescription = expireTime.description.toLowerCase(); | |
| contract.checkSpec( | |
| expiryDescription.includes('exclusive') && | |
| expiryDescription.includes('server clock') && | |
| expiryDescription.includes('millisecond') && | |
| expiryDescription.includes('must not authorize'), | |
| 'expireTime must define exclusive millisecond server-clock authorization semantics', | |
| ); | |
| const resourceDescription = contract.schema('PermissionResource').description.toLowerCase(); | |
| ['stable internal resource identity', 'renaming', 'dropping', 'recreating'].forEach((phrase) => | |
| contract.checkSpec( | |
| resourceDescription.includes(phrase), | |
| `PermissionResource lifecycle is missing: ${phrase}`, | |
| ), | |
| ); | |
| const policyDescription = contract.schema('DataPolicy').description.toLowerCase(); | |
| ['logical and', 'same column', 'fail closed'].forEach((phrase) => | |
| contract.checkSpec( | |
| policyDescription.includes(phrase), | |
| `DataPolicy composition is missing: ${phrase}`, | |
| ), | |
| ); | |
| contract.requireSchemaReference('PolicyRequest', 'oneOf', 'RowFilterPolicyRequest'); | |
| contract.requireSchemaReference('PolicyRequest', 'oneOf', 'ColumnMaskPolicyRequest'); | |
| contract.requireSchemaReference('DataPolicy', 'oneOf', 'RowFilterDataPolicy'); | |
| contract.requireSchemaReference('DataPolicy', 'oneOf', 'ColumnMaskDataPolicy'); | |
| const rowFilter = contract.requireProperties('RowFilter', ['predicate']); | |
| const columnMask = contract.requireProperties('ColumnMask', ['onColumn', 'transform']); | |
| contract.requireRequiredProperties('RowFilter', ['predicate']); | |
| contract.requireRequiredProperties('ColumnMask', ['onColumn', 'transform']); | |
| [rowFilter.predicate, columnMask.transform].forEach((definition) => { | |
| contract.checkSpec( | |
| definition.type === 'string' && | |
| definition.minLength === 1 && | |
| definition['x-maxUtf8Bytes'] === 61440 && | |
| definition.contentMediaType === 'application/json', | |
| 'Policy definitions must be bounded non-empty JSON strings', | |
| ); | |
| }); | |
| ['RowFilterPolicyRequest', 'ColumnMaskPolicyRequest'].forEach((schemaName) => { | |
| const properties = contract.requireProperties(schemaName, ['principal']); | |
| contract.checkSpec( | |
| properties.principal.$ref === '#/components/schemas/Principal', | |
| `${schemaName}.principal must reference Principal`, | |
| ); | |
| contract.checkSpec( | |
| !properties.type && | |
| !properties.resource && | |
| !properties.name && | |
| !properties.toPrincipals && | |
| !properties.exceptPrincipals, | |
| `${schemaName} must expose only one principal and no path identity`, | |
| ); | |
| contract.requireRequiredProperties(schemaName, ['principal']); | |
| }); | |
| contract.requireProperties('RowFilterPolicyRequest', ['rowFilter']); | |
| contract.requireProperties('ColumnMaskPolicyRequest', ['columnMask']); | |
| contract.requireRequiredProperties('RowFilterPolicyRequest', ['rowFilter']); | |
| contract.requireRequiredProperties('ColumnMaskPolicyRequest', ['columnMask']); | |
| contract.requireSchemaReference('DropPolicyRequest', 'oneOf', 'RowFilterPolicyIdentity'); | |
| contract.requireSchemaReference('DropPolicyRequest', 'oneOf', 'ColumnMaskPolicyIdentity'); | |
| contract.requireRequiredProperties('RowFilterPolicyIdentity', ['type', 'principal']); | |
| contract.requireRequiredProperties('ColumnMaskPolicyIdentity', [ | |
| 'type', | |
| 'principal', | |
| 'column', | |
| ]); | |
| const tablePolicyResource = contract.requireProperties('TablePolicyResource', [ | |
| 'type', | |
| 'database', | |
| 'table', | |
| ]); | |
| contract.checkSpec( | |
| tablePolicyResource.type.const === 'TABLE', | |
| 'Data policies must use a TABLE attachment resource', | |
| ); | |
| const policyList = contract.requireProperties('ListPoliciesResponse', [ | |
| 'policies', | |
| 'nextPageToken', | |
| ]); | |
| contract.checkSpec( | |
| policyList.policies.items.$ref === '#/components/schemas/DataPolicy', | |
| 'ListPoliciesResponse.policies must contain DataPolicy values', | |
| ); | |
| ['RowFilterDataPolicy', 'ColumnMaskDataPolicy'].forEach((schemaName) => { | |
| const properties = contract.requireProperties(schemaName, ['resource', 'principal']); | |
| contract.checkSpec( | |
| properties.principal.$ref === '#/components/schemas/Principal', | |
| `${schemaName}.principal must reference Principal`, | |
| ); | |
| contract.checkSpec( | |
| !properties.name && !properties.toPrincipals && !properties.exceptPrincipals, | |
| `${schemaName} must use a single principal identity`, | |
| ); | |
| }); | |
| contract.requireProperties('ErrorResponse', [ | |
| 'message', | |
| 'resourceType', | |
| 'resourceName', | |
| 'code', | |
| ]); | |
| contract.requireRequiredProperties('ErrorResponse', ['message', 'code']); | |
| const permissionParameters = contract | |
| .requireOperation('listPermissions') | |
| .parameters.map((parameter) => | |
| parameter.$ref ? parameter.$ref.split('/').pop() : parameter.name, | |
| ); | |
| [ | |
| 'ResourceTypeQuery', | |
| 'DatabaseQuery', | |
| 'TableQuery', | |
| 'FunctionQuery', | |
| 'ViewQuery', | |
| 'PrincipalQuery', | |
| 'AccessQuery', | |
| 'PageToken', | |
| 'MaxResults', | |
| ].forEach((name) => | |
| contract.checkSpec( | |
| permissionParameters.includes(name), | |
| `Operation listPermissions is missing query parameter: ${name}`, | |
| ), | |
| ); | |
| contract.checkSpec( | |
| permissionParameters.length === 9, | |
| 'Operation listPermissions must expose only exact-resource filters and pagination', | |
| ); | |
| contract.checkSpec( | |
| contract.spec.components.parameters.ResourceTypeQuery.required === true, | |
| 'Operation listPermissions must require resourceType', | |
| ); | |
| const policyParameters = contract | |
| .requireOperation('listTablePolicies') | |
| .parameters.map((parameter) => | |
| parameter.$ref ? parameter.$ref.split('/').pop() : parameter.name, | |
| ); | |
| ['PolicyTypeQuery', 'PrincipalQuery', 'PolicyColumnQuery', 'PageToken', 'MaxResults'].forEach( | |
| (name) => | |
| contract.checkSpec( | |
| policyParameters.includes(name), | |
| `Operation listTablePolicies is missing query parameter: ${name}`, | |
| ), | |
| ); | |
| contract.checkSpec( | |
| !policyParameters.includes('PolicyNameQuery'), | |
| 'Principal-scoped policies must not expose a policy-name filter', | |
| ); | |
| const tablePoliciesPath = | |
| contract.spec.paths['/v1/{prefix}/databases/{database}/tables/{table}/policies']; | |
| const dropTablePolicyPath = | |
| contract.spec.paths['/v1/{prefix}/databases/{database}/tables/{table}/policies/drop']; | |
| contract.checkSpec( | |
| tablePoliciesPath.get && | |
| tablePoliciesPath.post && | |
| !tablePoliciesPath.put && | |
| !tablePoliciesPath.delete, | |
| 'The table policy collection must expose only list and strict creation', | |
| ); | |
| contract.checkSpec( | |
| dropTablePolicyPath.post && !dropTablePolicyPath.delete, | |
| 'Policy deletion must use a body-bearing POST action instead of DELETE', | |
| ); | |
| contract.checkSpec( | |
| contract.operations.size === operationIds.length, | |
| `The management contract must define exactly ${operationIds.length} operations`, | |
| ); | |
| resourcePaths.forEach((resourcePath) => | |
| contract.checkSpec( | |
| contract.spec.paths[resourcePath].parameters.some( | |
| (parameter) => parameter.$ref === '#/components/parameters/Prefix', | |
| ), | |
| `Path ${resourcePath} must reuse components.parameters.Prefix`, | |
| ), | |
| ); | |
| return contract.operations.size; | |
| } | |
| const catalogOperationCount = validateCatalogOpenApi(); | |
| const managementOperationCount = validateManagementOpenApi(); | |
| console.log(`Validated REST Catalog OpenAPI contract with ${catalogOperationCount} operations.`); | |
| console.log( | |
| `Validated REST Management OpenAPI contract with ${managementOperationCount} operations.`, | |
| ); |