diff --git a/.changeset/twelve-bobcats-grow.md b/.changeset/twelve-bobcats-grow.md new file mode 100644 index 0000000000..302af3c60e --- /dev/null +++ b/.changeset/twelve-bobcats-grow.md @@ -0,0 +1,14 @@ +--- +'@backstage/catalog-model': minor +--- + +Breaking changes: + +- The long-deprecated `schemaValidator` export is finally removed. + +Additions: + +- The `EntityEnvelope` type, which is a supertype of `Entity`. +- The `entityEnvelopeSchemaValidator` function, which returns a validator for an `EntityEnvelope` or its subtypes, based on a JSON schema. +- The `entitySchemaValidator` function, which returns a validator for an `Entity` or its subtypes, based on a JSON schema. +- The `entityKindSchemaValidator` function, which returns a specialized validator for custom `Entity` kinds, based on a JSON schema. diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 7840b331e1..c367fa0a4c 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -1,133 +1,58 @@ -Apdex -Api -automations -Autoscaling -Avro -Bigtable -Billett -Bitrise -Blackbox -Chai -Changesets -Chanwit -Cloudformation -Cobertura -Codecov -Codehilite -Config -Datadog -Debounce -Discoverability -Dockerfile -dockerfiles -Dockerize -Docusaurus -Env -Expedia -Fargate -Figma -Firekube -Fiverr -GitHub -GitLab -Grafana -GraphQL -Hackathons -Helidon -Heroku -hoc -Hostname -Iain -JaCoCo -JavaScript -Jira -Kaewkasi -kubernetes -Knex -Leasot -Lerna -LocalStack -Luxon -Minikube -Minio -Mkdocs -Monorepo -microservices -microservice -Namespaces -OAuth -Okta -Onboarding -Platformize -Podman -Preprarer -Protobuf -Proxying -Readme -readonly -rebase -Recharts -Redash -Repo -Rollbar -Rollup -Routable -Scaffolder -Serverless -Sinon -Snyk -Splunk -Spotifiers -Spotify -Superfences -Talkdesk -Telenor -Templater -Templaters -todo -Tolerations -WWW -Weaveworks -Webpack -XML -Zalando -Zhou abc accessors +Apdex api +Api apis args asciidoc async +automations autoscaling +Autoscaling +Avro backrub +Bigtable +Billett +Bitrise +Blackbox bool boolean builtins +Chai changeset changesets +Changesets chanwit +Chanwit ci cisphobia cissexist classname cli cloudbuild +Cloudformation cncf +Cobertura codeblocks +Codecov codehilite +Codehilite codeowners composability composable config +Config configmaps configs const cookiecutter css +Datadog dataflow deadnaming debounce +Debounce declaratively deps destructured @@ -135,60 +60,98 @@ dev devops devs discoverability +Discoverability dls docgen +Dockerfile +dockerfiles +Dockerize dockerode +Docusaurus env +Env esbuild eslint etag +Expedia facto failover +Fargate +Figma +Firekube +Fiverr gitbeaker +GitHub +GitLab +Grafana graphql +GraphQL graphviz +Hackathons haproxy +Helidon +Heroku +hoc horizontalpodautoscalers +Hostname hotspots html http https +Iain img incentivised inlined inlinehilite interop +JaCoCo +JavaScript +Jira jq js json jsonnet jsx +Kaewkasi +Knex kubectl kubernetes +kubernetes learnings +Leasot lerna +Lerna +LocalStack lockfile lunr +Luxon magiclink mailto maintainership makefile md memcache +microservice +microservices microsite middleware minikube +Minikube +Minio misconfiguration misconfigured misgendering mkdocs +Mkdocs monorepo +Monorepo monorepos msw mysql namespace namespaced namespaces +Namespaces namespacing neuro newrelic @@ -200,43 +163,67 @@ noop npm nvarchar nvm +OAuth octokit oidc +Okta onboarding +Onboarding pagerduty pageview parallelization plantuml +Platformize +Podman postgres postpack pre prebaked preconfigured prepack +Preprarer productional +Protobuf proxying +Proxying pygments pymdownx rankdir readme +Readme +readonly +rebase +Recharts +Redash replicasets repo +Repo repos rerender rollbar +Rollbar +Rollup routable +Routable rst rsync ruleset sam scaffolded scaffolder +Scaffolder seb semlas semver +Serverless +Sinon +Snyk sourcemaps sparklines +Splunk +Spotifiers spotify +Spotify sqlite squidfunk src @@ -247,17 +234,25 @@ subkey subroutes subtree superfences +Superfences superset +supertype talkdesk +Talkdesk tasklist techdocs +Telenor templated templater +Templater templaters +Templaters theia thumbsup toc +todo tolerations +Tolerations toolchain toolsets tooltip @@ -273,11 +268,18 @@ untracked upvote url utils +validator validators varchar +Weaveworks +Webpack winston www +WWW +XML xyz yaml +Zalando +Zhou zoomable zsh diff --git a/packages/catalog-model/api-report.md b/packages/catalog-model/api-report.md index 7b5542fcd6..688f5ad249 100644 --- a/packages/catalog-model/api-report.md +++ b/packages/catalog-model/api-report.md @@ -18,9 +18,9 @@ export const analyzeLocationSchema: yup.ObjectSchema<{ // @public (undocumented) interface ApiEntityV1alpha1 extends Entity { // (undocumented) - apiVersion: typeof API_VERSION[number]; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; // (undocumented) - kind: typeof KIND; + kind: 'API'; // (undocumented) spec: { type: string; @@ -54,9 +54,9 @@ export function compareEntityToRef(entity: Entity, ref: EntityRef | EntityName, // @public (undocumented) interface ComponentEntityV1alpha1 extends Entity { // (undocumented) - apiVersion: typeof API_VERSION_2[number]; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; // (undocumented) - kind: typeof KIND_2; + kind: 'Component'; // (undocumented) spec: { type: string; @@ -87,9 +87,9 @@ export class DefaultNamespaceEntityPolicy implements EntityPolicy { // @public (undocumented) interface DomainEntityV1alpha1 extends Entity { // (undocumented) - apiVersion: typeof API_VERSION_3[number]; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; // (undocumented) - kind: typeof KIND_3; + kind: 'Domain'; // (undocumented) spec: { owner: string; @@ -107,7 +107,7 @@ export const domainEntityV1alpha1Validator: KindValidator; export const EDIT_URL_ANNOTATION = "backstage.io/edit-url"; // @public -export type Entity = { +export type Entity = EntityEnvelope & { apiVersion: string; kind: string; metadata: EntityMeta; @@ -122,9 +122,25 @@ export const ENTITY_DEFAULT_NAMESPACE = "default"; // @public export const ENTITY_META_GENERATED_FIELDS: readonly ["uid", "etag", "generation"]; +// @public +export type EntityEnvelope = { + apiVersion: string; + kind: string; + metadata: { + name: string; + namespace?: string; + }; +}; + +// @public +export function entityEnvelopeSchemaValidator(schema?: unknown): (data: unknown) => T; + // @public export function entityHasChanges(previous: Entity, next: Entity): boolean; +// @public +export function entityKindSchemaValidator(schema: unknown): (data: unknown) => T | false; + // @public export type EntityLink = { url: string; @@ -184,6 +200,9 @@ export type EntityRelationSpec = { target: EntityName; }; +// @public +export function entitySchemaValidator(schema?: unknown): (data: unknown) => T; + // @public export class FieldFormatEntityPolicy implements EntityPolicy { constructor(validators?: Validators); @@ -212,9 +231,9 @@ export function getEntitySourceLocation(entity: Entity): { // @public (undocumented) interface GroupEntityV1alpha1 extends Entity { // (undocumented) - apiVersion: typeof API_VERSION_4[number]; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; // (undocumented) - kind: typeof KIND_4; + kind: 'Group'; // (undocumented) spec: { type: string; @@ -279,9 +298,9 @@ export const LOCATION_ANNOTATION = "backstage.io/managed-by-location"; // @public (undocumented) interface LocationEntityV1alpha1 extends Entity { // (undocumented) - apiVersion: typeof API_VERSION_5[number]; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; // (undocumented) - kind: typeof KIND_5; + kind: 'Location'; // (undocumented) spec: { type?: string; @@ -405,9 +424,9 @@ export const RELATION_PROVIDES_API = "providesApi"; // @public (undocumented) interface ResourceEntityV1alpha1 extends Entity { // (undocumented) - apiVersion: typeof API_VERSION_6[number]; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; // (undocumented) - kind: typeof KIND_6; + kind: 'Resource'; // (undocumented) spec: { type: string; @@ -424,9 +443,6 @@ export { ResourceEntityV1alpha1 } // @public (undocumented) export const resourceEntityV1alpha1Validator: KindValidator; -// @public @deprecated (undocumented) -export function schemaValidator(kind: string, apiVersion: readonly string[], schema: yup.Schema): KindValidator; - // @public export class SchemaValidEntityPolicy implements EntityPolicy { // (undocumented) @@ -459,9 +475,9 @@ export function stringifyLocationReference(ref: { // @public (undocumented) interface SystemEntityV1alpha1 extends Entity { // (undocumented) - apiVersion: typeof API_VERSION_7[number]; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; // (undocumented) - kind: typeof KIND_7; + kind: 'System'; // (undocumented) spec: { owner: string; @@ -479,9 +495,9 @@ export const systemEntityV1alpha1Validator: KindValidator; // @public (undocumented) interface TemplateEntityV1alpha1 extends Entity { // (undocumented) - apiVersion: typeof API_VERSION_8[number]; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; // (undocumented) - kind: typeof KIND_8; + kind: 'Template'; // (undocumented) spec: { type: string; @@ -502,9 +518,9 @@ export const templateEntityV1alpha1Validator: KindValidator; // @public (undocumented) export interface TemplateEntityV1beta2 extends Entity { // (undocumented) - apiVersion: typeof API_VERSION_9[number]; + apiVersion: 'backstage.io/v1beta2'; // (undocumented) - kind: typeof KIND_9; + kind: 'Template'; // (undocumented) metadata: EntityMeta & { title?: string; @@ -548,9 +564,9 @@ export type UNSTABLE_EntityStatusLevel = 'info' | 'warning' | 'error'; // @public (undocumented) interface UserEntityV1alpha1 extends Entity { // (undocumented) - apiVersion: typeof API_VERSION_10[number]; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; // (undocumented) - kind: typeof KIND_10; + kind: 'User'; // (undocumented) spec: { profile?: { diff --git a/packages/catalog-model/package.json b/packages/catalog-model/package.json index 97ce0e6568..ffb94cb0f8 100644 --- a/packages/catalog-model/package.json +++ b/packages/catalog-model/package.json @@ -35,6 +35,7 @@ "@types/yup": "^0.29.8", "ajv": "^7.0.3", "json-schema": "^0.3.0", + "typescript-json-schema": "^0.49.0", "lodash": "^4.17.15", "uuid": "^8.0.0", "yup": "^0.29.3" diff --git a/packages/catalog-model/src/entity/Entity.ts b/packages/catalog-model/src/entity/Entity.ts index 3b5a0621bb..2fdf1a93c6 100644 --- a/packages/catalog-model/src/entity/Entity.ts +++ b/packages/catalog-model/src/entity/Entity.ts @@ -16,14 +16,15 @@ import { JsonObject } from '@backstage/config'; import { EntityName } from '../types'; +import { EntityEnvelope } from './EntityEnvelope'; import { UNSTABLE_EntityStatus } from './EntityStatus'; /** - * The format envelope that's common to all versions/kinds of entity. + * The parts of the format that's common to all versions/kinds of entity. * * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/ */ -export type Entity = { +export type Entity = EntityEnvelope & { /** * The version of specification format for this particular entity that * this is written against. diff --git a/packages/catalog-model/src/entity/EntityEnvelope.ts b/packages/catalog-model/src/entity/EntityEnvelope.ts new file mode 100644 index 0000000000..631a8873c7 --- /dev/null +++ b/packages/catalog-model/src/entity/EntityEnvelope.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed 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. + */ + +/** + * The envelope skeleton parts of an entity - whatever is necessary to be able + * to give it a ref and pass to further validation / policy checking. + * + * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/ + */ +export type EntityEnvelope = { + /** + * The version of specification format for this particular entity that + * this is written against. + */ + apiVersion: string; + + /** + * The high level entity type being described. + */ + kind: string; + + /** + * Metadata related to the entity. + */ + metadata: { + /** + * The name of the entity. + * + * Must be unique within the catalog at any given point in time, for any + * given namespace + kind pair. + */ + name: string; + + /** + * The namespace that the entity belongs to. + */ + namespace?: string; + }; +}; diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index 2ccd205d85..ae2c0bf503 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -27,6 +27,7 @@ export type { EntityRelation, EntityRelationSpec, } from './Entity'; +export type { EntityEnvelope } from './EntityEnvelope'; export type { UNSTABLE_EntityStatus, UNSTABLE_EntityStatusItem, diff --git a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts index 5432cafdeb..37d5a4fba0 100644 --- a/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ApiEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/API.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'API' as const; - export interface ApiEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'API'; spec: { type: string; lifecycle: string; @@ -37,8 +31,5 @@ export interface ApiEntityV1alpha1 extends Entity { } export const apiEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts index 489aa8b1bd..6ef45fda6e 100644 --- a/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ComponentEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Component.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'Component' as const; - export interface ComponentEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'Component'; spec: { type: string; lifecycle: string; @@ -40,8 +34,5 @@ export interface ComponentEntityV1alpha1 extends Entity { } export const componentEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts index 7aab35e367..f23c330a87 100644 --- a/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/DomainEntityV1alpha1.ts @@ -16,25 +16,16 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Domain.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'Domain' as const; - export interface DomainEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'Domain'; spec: { owner: string; }; } export const domainEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts index 74ca4f221a..62a6edbc5e 100644 --- a/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/GroupEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Group.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'Group' as const; - export interface GroupEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'Group'; spec: { type: string; profile?: { @@ -41,8 +35,5 @@ export interface GroupEntityV1alpha1 extends Entity { } export const groupEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts index fb452b6ac7..dc79ff1921 100644 --- a/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/LocationEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Location.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'Location' as const; - export interface LocationEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'Location'; spec: { type?: string; target?: string; @@ -35,8 +29,5 @@ export interface LocationEntityV1alpha1 extends Entity { } export const locationEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts index fd71500f40..4c79209c9c 100644 --- a/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/ResourceEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Resource.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'Resource' as const; - export interface ResourceEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'Resource'; spec: { type: string; owner: string; @@ -36,8 +30,5 @@ export interface ResourceEntityV1alpha1 extends Entity { } export const resourceEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts index 1ee19466f0..41203083a5 100644 --- a/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/SystemEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/System.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'System' as const; - export interface SystemEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'System'; spec: { owner: string; domain?: string; @@ -34,8 +28,5 @@ export interface SystemEntityV1alpha1 extends Entity { } export const systemEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts index 64721e3739..0600c58278 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1alpha1.ts @@ -16,18 +16,12 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/Template.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import type { JSONSchema } from '../types'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'Template' as const; - export interface TemplateEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'Template'; spec: { type: string; templater: string; @@ -38,8 +32,5 @@ export interface TemplateEntityV1alpha1 extends Entity { } export const templateEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts index 7a100de4bb..98b953c9e0 100644 --- a/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts +++ b/packages/catalog-model/src/kinds/TemplateEntityV1beta2.ts @@ -14,20 +14,14 @@ * limitations under the License. */ +import { JsonObject } from '@backstage/config'; import type { Entity, EntityMeta } from '../entity/Entity'; import schema from '../schema/kinds/Template.v1beta2.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -import { JsonObject } from '@backstage/config'; - -const API_VERSION = ['backstage.io/v1beta2'] as const; -const KIND = 'Template' as const; export interface TemplateEntityV1beta2 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1beta2'; + kind: 'Template'; metadata: EntityMeta & { title?: string; }; @@ -46,8 +40,5 @@ export interface TemplateEntityV1beta2 extends Entity { } export const templateEntityV1beta2Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts index a8700a496e..d73fa7aaf3 100644 --- a/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts +++ b/packages/catalog-model/src/kinds/UserEntityV1alpha1.ts @@ -16,17 +16,11 @@ import type { Entity } from '../entity/Entity'; import schema from '../schema/kinds/User.v1alpha1.schema.json'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; import { ajvCompiledJsonSchemaValidator } from './util'; -const API_VERSION = ['backstage.io/v1alpha1', 'backstage.io/v1beta1'] as const; -const KIND = 'User' as const; - export interface UserEntityV1alpha1 extends Entity { - apiVersion: typeof API_VERSION[number]; - kind: typeof KIND; + apiVersion: 'backstage.io/v1alpha1' | 'backstage.io/v1beta1'; + kind: 'User'; spec: { profile?: { displayName?: string; @@ -38,8 +32,5 @@ export interface UserEntityV1alpha1 extends Entity { } export const userEntityV1alpha1Validator = ajvCompiledJsonSchemaValidator( - KIND, - API_VERSION, schema, - [commonSchema, entityMetaSchema, entitySchema], ); diff --git a/packages/catalog-model/src/kinds/index.ts b/packages/catalog-model/src/kinds/index.ts index 4ae2db483a..e36575f51d 100644 --- a/packages/catalog-model/src/kinds/index.ts +++ b/packages/catalog-model/src/kinds/index.ts @@ -14,8 +14,6 @@ * limitations under the License. */ -export { schemaValidator } from './util'; -export type { KindValidator } from './types'; export { apiEntityV1alpha1Validator } from './ApiEntityV1alpha1'; export type { ApiEntityV1alpha1 as ApiEntity, @@ -59,6 +57,7 @@ export type { } from './TemplateEntityV1alpha1'; export { templateEntityV1beta2Validator } from './TemplateEntityV1beta2'; export type { TemplateEntityV1beta2 } from './TemplateEntityV1beta2'; +export type { KindValidator } from './types'; export { userEntityV1alpha1Validator } from './UserEntityV1alpha1'; export type { UserEntityV1alpha1 as UserEntity, diff --git a/packages/catalog-model/src/kinds/util.ts b/packages/catalog-model/src/kinds/util.ts index 1e9e7b7521..a907df7f8a 100644 --- a/packages/catalog-model/src/kinds/util.ts +++ b/packages/catalog-model/src/kinds/util.ts @@ -14,66 +14,18 @@ * limitations under the License. */ -import Ajv, { AnySchema } from 'ajv'; -import * as yup from 'yup'; +import { entityKindSchemaValidator } from '../validation'; import { KindValidator } from './types'; -/** - * @deprecated We no longer use yup for the catalog model. This utility method will be removed. - */ -export function schemaValidator( - kind: string, - apiVersion: readonly string[], - schema: yup.Schema, -): KindValidator { +// TODO(freben): Left here as a compatibility helper. It would be nicer to +// just export the inner validator directly. However, all of the already +// exported kind validators have the `KindValidator` signature which is +// different. So let's postpone that change until a later time. +export function ajvCompiledJsonSchemaValidator(schema: unknown): KindValidator { + const validator = entityKindSchemaValidator(schema); return { - async check(envelope) { - if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) { - return false; - } - await schema.validate(envelope, { strict: true }); - return true; - }, - }; -} - -export function ajvCompiledJsonSchemaValidator( - kind: string, - apiVersion: readonly string[], - schema: AnySchema, - extraSchemas?: AnySchema[], -): KindValidator { - const ajv = new Ajv({ allowUnionTypes: true }); - if (extraSchemas) { - ajv.addSchema(extraSchemas, undefined, undefined, true); - } - const validate = ajv.compile(schema); - - return { - async check(envelope) { - if (kind !== envelope.kind || !apiVersion.includes(envelope.apiVersion)) { - return false; - } - - const result = validate(envelope); - if (result === true) { - return true; - } - - const [error] = validate.errors || []; - if (!error) { - throw new TypeError(`Malformed ${kind}, Unknown error`); - } - - throw new TypeError( - `Malformed ${kind}, ${error.dataPath || ''} ${error.message}${ - error.params - ? ` - ${Object.entries(error.params) - .map(([key, val]) => `${key}: ${val}`) - .join(', ')}` - : '' - }`, - ); + async check(data) { + return validator(data) === data; }, }; } diff --git a/packages/catalog-model/src/schema/Entity.schema.json b/packages/catalog-model/src/schema/Entity.schema.json index b8c43dda9e..90eb9dd5c6 100644 --- a/packages/catalog-model/src/schema/Entity.schema.json +++ b/packages/catalog-model/src/schema/Entity.schema.json @@ -1,7 +1,7 @@ { "$schema": "http://json-schema.org/draft-07/schema", "$id": "Entity", - "description": "The format envelope that's common to all versions/kinds of entity.", + "description": "The parts of the format that's common to all versions/kinds of entity.", "examples": [ { "apiVersion": "backstage.io/v1alpha1", diff --git a/packages/catalog-model/src/schema/EntityEnvelope.schema.json b/packages/catalog-model/src/schema/EntityEnvelope.schema.json new file mode 100644 index 0000000000..7e64039c75 --- /dev/null +++ b/packages/catalog-model/src/schema/EntityEnvelope.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "EntityEnvelope", + "description": "The envelope skeleton parts of an entity - whatever is necessary to be able to give it a ref and pass to further validation / policy checking.", + "examples": [ + { + "apiVersion": "backstage.io/v1alpha1", + "kind": "Component", + "metadata": { + "name": "LoremService" + } + } + ], + "type": "object", + "required": ["apiVersion", "kind", "metadata"], + "additionalProperties": true, + "properties": { + "apiVersion": { + "type": "string", + "description": "The version of specification format for this particular entity that this is written against.", + "minLength": 1, + "examples": ["backstage.io/v1alpha1", "my-company.net/v1", "1.0"] + }, + "kind": { + "type": "string", + "description": "The high level entity type being described.", + "minLength": 1, + "examples": [ + "API", + "Component", + "Domain", + "Group", + "Location", + "Resource", + "System", + "Template", + "User" + ] + }, + "metadata": { + "type": "object", + "required": ["name"], + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "The name of the entity. Must be unique within the catalog at any given point in time, for any given namespace + kind pair.", + "examples": ["metadata-proxy"], + "minLength": 1 + }, + "namespace": { + "type": "string", + "description": "The namespace that the entity belongs to.", + "default": "default", + "examples": ["default", "admin"], + "minLength": 1 + } + } + } + } +} diff --git a/packages/catalog-model/src/validation/ajv.ts b/packages/catalog-model/src/validation/ajv.ts new file mode 100644 index 0000000000..81f17ebab7 --- /dev/null +++ b/packages/catalog-model/src/validation/ajv.ts @@ -0,0 +1,137 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed 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. + */ + +import Ajv, { Schema, ValidateFunction } from 'ajv'; +import entitySchema from '../schema/Entity.schema.json'; +import entityEnvelopeSchema from '../schema/EntityEnvelope.schema.json'; +import entityMetaSchema from '../schema/EntityMeta.schema.json'; +import commonSchema from '../schema/shared/common.schema.json'; + +// A local cache of compiled schemas, to avoid duplicate work +const compiledSchemaCache = new Map>(); + +// The core schemas that others can depend on +const refDependencyCandidates = [ + entityEnvelopeSchema, + entitySchema, + entityMetaSchema, + commonSchema, +]; + +export function throwAjvError( + errors: ValidateFunction['errors'], +): never { + if (!errors?.length) { + throw new TypeError('Unknown error'); + } + + const error = errors[0]; + throw new TypeError( + `${error.dataPath || ''} ${error.message}${ + error.params + ? ` - ${Object.entries(error.params) + .map(([key, val]) => `${key}: ${val}`) + .join(', ')}` + : '' + }`, + ); +} + +// Compiles the given schema, and makes sure to also grab any core dependencies +// that it depends on +export function compileAjvSchema( + schema: Schema, + options: { disableCache?: boolean } = {}, +): ValidateFunction { + const disableCache = options?.disableCache ?? false; + + if (!disableCache) { + const cached = compiledSchemaCache.get(schema); + if (cached) { + return cached; + } + } + + const extraSchemas = getExtraSchemas(schema); + const ajv = new Ajv({ + allowUnionTypes: true, + allErrors: true, + validateSchema: true, + }); + if (extraSchemas.length) { + ajv.addSchema(extraSchemas, undefined, undefined, true); + } + const compiled = ajv.compile(schema); + + if (!disableCache) { + compiledSchemaCache.set(schema, compiled); + } + + return compiled; +} + +// Find refs in the given schema and recursively in all known schemas it +// targets, collecting that list of schemas as we go +function getExtraSchemas(schema: Schema): Schema[] { + if (typeof schema !== 'object') { + return []; + } + + const seen = new Set(); + if (schema.$id) { + seen.add(schema.$id); + } + + const selected = new Array(); + + const todo: Schema[] = [schema]; + while (todo.length) { + const current = todo.pop()!; + + for (const ref of getAllRefs(current)) { + if (!seen.has(ref)) { + seen.add(ref); + + const match = refDependencyCandidates.find(c => c.$id === ref); + if (match) { + selected.push(match); + todo.push(match); + } + } + } + } + + return selected; +} + +// Naively step through the entire schema looking for "$ref": "x" pairs. The +// resulting iterator may contain duplicates. Ignores fragments, i.e. for a ref +// of "a#b", it will just yield "a". +function* getAllRefs(schema: Schema): Iterable { + const todo: any[] = [schema]; + while (todo.length) { + const current = todo.pop()!; + if (typeof current === 'object' && current) { + for (const [key, value] of Object.entries(current)) { + if (key === '$ref' && typeof value === 'string') { + yield value.split('#')[0]; + } else { + todo.push(value); + } + } + } + } +} diff --git a/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.test.ts b/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.test.ts new file mode 100644 index 0000000000..7c6613936c --- /dev/null +++ b/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.test.ts @@ -0,0 +1,129 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed 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. + */ + +import { entityEnvelopeSchemaValidator } from './entityEnvelopeSchemaValidator'; + +describe('entityEnvelopeSchemaValidator', () => { + const validator = entityEnvelopeSchemaValidator(); + let entity: any; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'test', + namespace: 'ns', + }, + }; + }); + + it('happy path: accepts valid data', () => { + expect(() => validator(entity)).not.toThrow(); + }); + + // + // apiVersion and kind + // + + it('rejects wrong root type', () => { + expect(() => validator(7)).toThrow(/object/); + }); + + it('accepts unknown root fields', () => { + entity.blah = 7; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects missing apiVersion', () => { + delete entity.apiVersion; + expect(() => validator(entity)).toThrow(/apiVersion/); + }); + + it('rejects bad apiVersion type', () => { + entity.apiVersion = 7; + expect(() => validator(entity)).toThrow(/apiVersion/); + }); + + it('rejects empty apiVersion', () => { + entity.apiVersion = ''; + expect(() => validator(entity)).toThrow(/apiVersion/); + }); + + it('rejects missing kind', () => { + delete entity.kind; + expect(() => validator(entity)).toThrow(/kind/); + }); + + it('rejects bad kind type', () => { + entity.kind = 7; + expect(() => validator(entity)).toThrow(/kind/); + }); + + it('rejects empty kind', () => { + entity.kind = ''; + expect(() => validator(entity)).toThrow(/kind/); + }); + + // + // metadata + // + + it('rejects missing metadata', () => { + delete entity.metadata; + expect(() => validator(entity)).toThrow(/metadata/); + }); + + it('rejects bad metadata type', () => { + entity.metadata = 7; + expect(() => validator(entity)).toThrow(/metadata/); + }); + + it('rejects missing name', () => { + delete entity.metadata.name; + expect(() => validator(entity)).toThrow(/name/); + }); + + it('rejects empty name', () => { + entity.metadata.name = ''; + expect(() => validator(entity)).toThrow(/name/); + }); + + it('rejects bad name type', () => { + entity.metadata.name = 7; + expect(() => validator(entity)).toThrow(/name/); + }); + + it('accepts missing namespace', () => { + delete entity.metadata.namespace; + expect(() => validator(entity)).not.toThrow(); + }); + + it('rejects empty namespace', () => { + entity.metadata.namespace = ''; + expect(() => validator(entity)).toThrow(/namespace/); + }); + + it('rejects bad namespace type', () => { + entity.metadata.namespace = 7; + expect(() => validator(entity)).toThrow(/namespace/); + }); + + it('accepts unknown metadata fields', () => { + entity.metadata.blah = 7; + expect(() => validator(entity)).not.toThrow(); + }); +}); diff --git a/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.ts b/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.ts new file mode 100644 index 0000000000..52ae00e399 --- /dev/null +++ b/packages/catalog-model/src/validation/entityEnvelopeSchemaValidator.ts @@ -0,0 +1,58 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed 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. + */ + +import { Schema } from 'ajv'; +import { EntityEnvelope } from '../entity/EntityEnvelope'; +import entityEnvelopeSchema from '../schema/EntityEnvelope.schema.json'; +import { compileAjvSchema, throwAjvError } from './ajv'; + +/** + * Creates a validation function that takes some arbitrary data, and either + * returns that data cast to an {@link EntityEnvelope} (or the given subtype) + * if it matches that schema, or throws a {@link TypeError} describing the + * errors. + * + * Note that this validator is only meant for applying the base schema checks; + * it does not take custom policies or additional processor based validation + * into account. + * + * By default, the plain `EntityEnvelope` schema is used. If you pass in your + * own, it may contain `$ref` references to the following, which are resolved + * automatically for you: + * + * - EntityEnvelope + * - Entity + * - EntityMeta + * - common# + * + * @see https://github.com/backstage/backstage/tree/master/packages/catalog-model/src/schema + */ +export function entityEnvelopeSchemaValidator< + T extends EntityEnvelope = EntityEnvelope +>(schema?: unknown): (data: unknown) => T { + const validate = compileAjvSchema( + schema ? (schema as Schema) : entityEnvelopeSchema, + ); + + return data => { + const result = validate(data); + if (result === true) { + return data as T; + } + + throw throwAjvError(validate.errors); + }; +} diff --git a/packages/catalog-model/src/validation/entityKindSchemaValidator.test.ts b/packages/catalog-model/src/validation/entityKindSchemaValidator.test.ts new file mode 100644 index 0000000000..4b258aed14 --- /dev/null +++ b/packages/catalog-model/src/validation/entityKindSchemaValidator.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed 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. + */ + +import { entityKindSchemaValidator } from './entityKindSchemaValidator'; +import componentSchema from '../schema/kinds/Component.v1alpha1.schema.json'; + +describe('entityKindSchemaValidator', () => { + const validator = entityKindSchemaValidator(componentSchema); + let entity: any; + + beforeEach(() => { + entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + uid: 'e01199ab-08cc-44c2-8e19-5c29ded82521', + etag: 'lsndfkjsndfkjnsdfkjnsd==', + generation: 13, + name: 'test', + namespace: 'ns', + labels: { + 'backstage.io/custom': 'ValueStuff', + }, + annotations: { + 'example.com/bindings': 'are-secret', + }, + tags: ['java', 'data'], + links: [ + { + url: 'https://example.com', + title: 'Website', + icon: 'website', + }, + ], + }, + spec: { + type: 'service', + lifecycle: 'production', + owner: 'me', + }, + relations: [ + { type: 't', target: { kind: 'k', namespace: 'ns', name: 'n' } }, + ], + status: { + items: [ + { + type: 't', + level: 'error', + message: 'm', + error: { name: 'n', message: 'm', code: '1', stack: 's' }, + }, + ], + }, + }; + }); + + it('works in the happy path', () => { + expect(validator(entity)).toBe(entity); + }); + + it('nicely rejects an unknown kind', () => { + entity.kind = 'Unknown'; + expect(validator(entity)).toBe(false); + }); + + it('nicely rejects an unknown apiVersion', () => { + entity.apiVersion = 'backstage.io/v1alpha7'; + expect(validator(entity)).toBe(false); + }); + + it('nicely rejects when both kind and apiVersion mismatch', () => { + entity.apiVersion = 'backstage.io/v1alpha7'; + entity.kind = 'Unknown'; + expect(validator(entity)).toBe(false); + }); + + it('rejects when the kind is actually breaking other rules than enum', () => { + entity.kind = 7; + expect(() => validator(entity)).toThrow(/kind/); + }); + + it('rejects when the apiVersion is actually breaking other rules than enum', () => { + entity.apiVersion = 7; + expect(() => validator(entity)).toThrow(/apiVersion/); + }); + + it('rejects nicely when there is both a nice mismatch and a fatal error', () => { + entity.kind = 'Unknown'; + entity.metadata = 7; + expect(validator(entity)).toBe(false); + }); + + it('rejects on errors in other parts of the schema', () => { + entity.spec = 7; + expect(() => validator(entity)).toThrow(/spec/); + }); +}); diff --git a/packages/catalog-model/src/validation/entityKindSchemaValidator.ts b/packages/catalog-model/src/validation/entityKindSchemaValidator.ts new file mode 100644 index 0000000000..c722687f9e --- /dev/null +++ b/packages/catalog-model/src/validation/entityKindSchemaValidator.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed 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. + */ + +import { Schema } from 'ajv'; +import { Entity } from '../entity'; +import { compileAjvSchema, throwAjvError } from './ajv'; + +/** + * Creates a validation function that takes some arbitrary data, and either + * returns that data cast to a `T` if it matches that schema, or `false` if the + * schema apiVersion/kind didn't apply to that data, or throws a + * {@link TypeError} describing actual errors. + * + * This validator is highly specialized, in that it has special treatment of + * the `kind` and `apiVersion` root keys. This only works if your schema has + * their rule set to `"enum"`: + * + *
+ *   "apiVersion": {
+ *      "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
+ *   },
+ *   "kind": {
+ *     "enum": ["Group"]
+ *   },
+ * 
+ * + * In the above example, the created validator will return `false` if and only + * if the kind and/or apiVersion mismatch. + * + * Note that this validator is only meant for applying the base schema checks; + * it does not take custom policies or additional processor based validation + * into account. + * + * The given schema may contain `$ref` references to the following, which are + * resolved automatically for you: + * + * - EntityEnvelope + * - Entity + * - EntityMeta + * - common# + * + * @see https://github.com/backstage/backstage/tree/master/packages/catalog-model/src/schema + */ +export function entityKindSchemaValidator( + schema: unknown, +): (data: unknown) => T | false { + const validate = compileAjvSchema(schema as Schema); + + return data => { + const result = validate(data); + if (result === true) { + return data as T; + } + + // Only in the case where kind and/or apiVersion have enum mismatches AND + // have NO other errors, we call it a soft error. + const softCandidates = validate.errors?.filter(e => + ['/kind', '/apiVersion'].includes(e.dataPath), + ); + if ( + softCandidates?.length && + softCandidates.every(e => e.keyword === 'enum') + ) { + return false; + } + + throw throwAjvError(validate.errors); + }; +} diff --git a/packages/catalog-model/src/entity/Entity.test.ts b/packages/catalog-model/src/validation/entitySchemaValidator.test.ts similarity index 65% rename from packages/catalog-model/src/entity/Entity.test.ts rename to packages/catalog-model/src/validation/entitySchemaValidator.test.ts index 9f3bfc6813..6ab2744c0f 100644 --- a/packages/catalog-model/src/entity/Entity.test.ts +++ b/packages/catalog-model/src/validation/entitySchemaValidator.test.ts @@ -14,16 +14,11 @@ * limitations under the License. */ -import Ajv from 'ajv'; -import { AnyValidateFunction } from 'ajv/dist/core'; -import entitySchema from '../schema/Entity.schema.json'; -import entityMetaSchema from '../schema/EntityMeta.schema.json'; -import commonSchema from '../schema/shared/common.schema.json'; -import { Entity } from './Entity'; +import { entitySchemaValidator } from './entitySchemaValidator'; -describe('Entity', () => { +describe('entitySchemaValidator', () => { + const validator = entitySchemaValidator(); let entity: any; - let validate: AnyValidateFunction; beforeEach(() => { entity = { @@ -69,38 +64,10 @@ describe('Entity', () => { ], }, }; - - const ajv = new Ajv({ allowUnionTypes: true }); - ajv.addSchema([entityMetaSchema, commonSchema], undefined, undefined, true); - validate = ajv.compile(entitySchema); }); - function check(data: unknown): () => true { - return () => { - const result = validate(data); - if (result === true) { - return true; - } - - const [error] = validate.errors || []; - if (!error) { - throw new TypeError('Unknown error'); - } - - throw new TypeError( - `${error.dataPath || ''} ${error.message}${ - error.params - ? ` - ${Object.entries(error.params) - .map(([key, val]) => `${key}: ${val}`) - .join(', ')}` - : '' - }`, - ); - }; - } - it('happy path: accepts valid data', () => { - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); // @@ -108,37 +75,37 @@ describe('Entity', () => { // it('rejects wrong root type', () => { - expect(check((7 as unknown) as Entity)).toThrow(/object/); + expect(() => validator(7)).toThrow(/object/); }); it('rejects missing apiVersion', () => { delete entity.apiVersion; - expect(check(entity)).toThrow(/apiVersion/); + expect(() => validator(entity)).toThrow(/apiVersion/); }); it('rejects bad apiVersion type', () => { entity.apiVersion = 7; - expect(check(entity)).toThrow(/apiVersion/); + expect(() => validator(entity)).toThrow(/apiVersion/); }); it('rejects empty apiVersion', () => { entity.apiVersion = ''; - expect(check(entity)).toThrow(/apiVersion/); + expect(() => validator(entity)).toThrow(/apiVersion/); }); it('rejects missing kind', () => { delete entity.kind; - expect(check(entity)).toThrow(/kind/); + expect(() => validator(entity)).toThrow(/kind/); }); it('rejects bad kind type', () => { entity.kind = 7; - expect(check(entity)).toThrow(/kind/); + expect(() => validator(entity)).toThrow(/kind/); }); it('rejects empty kind', () => { entity.kind = ''; - expect(check(entity)).toThrow(/kind/); + expect(() => validator(entity)).toThrow(/kind/); }); // @@ -147,202 +114,202 @@ describe('Entity', () => { it('rejects missing metadata', () => { delete entity.metadata; - expect(check(entity)).toThrow(/metadata/); + expect(() => validator(entity)).toThrow(/metadata/); }); it('rejects bad metadata type', () => { entity.metadata = 7; - expect(check(entity)).toThrow(/metadata/); + expect(() => validator(entity)).toThrow(/metadata/); }); it('accepts missing uid', () => { delete entity.metadata.uid; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad uid type', () => { entity.metadata.uid = 7; - expect(check(entity)).toThrow(/uid/); + expect(() => validator(entity)).toThrow(/uid/); }); it('rejects empty uid', () => { entity.metadata.uid = ''; - expect(check(entity)).toThrow(/uid/); + expect(() => validator(entity)).toThrow(/uid/); }); it('accepts missing etag', () => { delete entity.metadata.etag; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad etag type', () => { entity.metadata.etag = 7; - expect(check(entity)).toThrow(/etag/); + expect(() => validator(entity)).toThrow(/etag/); }); it('rejects empty etag', () => { entity.metadata.etag = ''; - expect(check(entity)).toThrow(/etag/); + expect(() => validator(entity)).toThrow(/etag/); }); it('accepts missing generation', () => { delete entity.metadata.generation; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad generation type', () => { entity.metadata.generation = 'a'; - expect(check(entity)).toThrow(/generation/); + expect(() => validator(entity)).toThrow(/generation/); }); it('rejects zero generation', () => { entity.metadata.generation = 0; - expect(check(entity)).toThrow(/generation/); + expect(() => validator(entity)).toThrow(/generation/); }); it('rejects non-integer generation', () => { entity.metadata.generation = 1.5; - expect(check(entity)).toThrow(/generation/); + expect(() => validator(entity)).toThrow(/generation/); }); it('rejects missing name', () => { delete entity.metadata.name; - expect(check(entity)).toThrow(/name/); + expect(() => validator(entity)).toThrow(/name/); }); it('rejects bad name type', () => { entity.metadata.name = 7; - expect(check(entity)).toThrow(/name/); + expect(() => validator(entity)).toThrow(/name/); }); it('accepts missing namespace', () => { delete entity.metadata.namespace; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad namespace type', () => { entity.metadata.namespace = 7; - expect(check(entity)).toThrow(/namespace/); + expect(() => validator(entity)).toThrow(/namespace/); }); it('accepts missing description', () => { delete entity.metadata.description; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad description type', () => { entity.metadata.description = 7; - expect(check(entity)).toThrow(/description/); + expect(() => validator(entity)).toThrow(/description/); }); it('accepts missing labels', () => { delete entity.metadata.labels; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('accepts empty labels', () => { entity.metadata.labels = {}; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad labels type', () => { entity.metadata.labels = 7; - expect(check(entity)).toThrow(/labels/); + expect(() => validator(entity)).toThrow(/labels/); }); it('accepts missing annotations', () => { delete entity.metadata.annotations; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('accepts empty annotations object', () => { entity.metadata.annotations = {}; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad annotations type', () => { entity.metadata.annotations = 7; - expect(check(entity)).toThrow(/annotations/); + expect(() => validator(entity)).toThrow(/annotations/); }); it('rejects bad tags type', () => { entity.metadata.tags = 7; - expect(check(entity)).toThrow(/tags/); + expect(() => validator(entity)).toThrow(/tags/); }); it('accepts empty tags', () => { entity.metadata.tags = []; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects empty tag', () => { entity.metadata.tags[0] = ''; - expect(check(entity)).toThrow(/tags/); + expect(() => validator(entity)).toThrow(/tags/); }); it('rejects bad tag type', () => { entity.metadata.tags[0] = 7; - expect(check(entity)).toThrow(/tags/); + expect(() => validator(entity)).toThrow(/tags/); }); it('accepts missing links', () => { delete entity.metadata.links; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('accepts empty links', () => { entity.metadata.links = []; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects empty links.url', () => { entity.metadata.links[0].url = ''; - expect(check(entity)).toThrow(/links/); + expect(() => validator(entity)).toThrow(/links/); }); it('rejects missing links.url', () => { delete entity.metadata.links[0].url; - expect(check(entity)).toThrow(/links/); + expect(() => validator(entity)).toThrow(/links/); }); it('rejects bad links.url type', () => { entity.metadata.links[0].url = 7; - expect(check(entity)).toThrow(/links/); + expect(() => validator(entity)).toThrow(/links/); }); it('rejects empty links.title', () => { entity.metadata.links[0].title = ''; - expect(check(entity)).toThrow(/links/); + expect(() => validator(entity)).toThrow(/links/); }); it('accepts missing links.title', () => { delete entity.metadata.links[0].title; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad links.title type', () => { entity.metadata.links[0].title = 7; - expect(check(entity)).toThrow(/links/); + expect(() => validator(entity)).toThrow(/links/); }); it('rejects empty links.icon', () => { entity.metadata.links[0].icon = ''; - expect(check(entity)).toThrow(/links/); + expect(() => validator(entity)).toThrow(/links/); }); it('accepts missing links.icon', () => { delete entity.metadata.links[0].icon; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad links.icon type', () => { entity.metadata.links[0].icon = 7; - expect(check(entity)).toThrow(/links/); + expect(() => validator(entity)).toThrow(/links/); }); it('accepts unknown metadata field', () => { entity.metadata.unknown = 7; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); // @@ -351,17 +318,17 @@ describe('Entity', () => { it('accepts missing spec', () => { delete entity.spec; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects non-object spec', () => { entity.spec = 7; - expect(check(entity)).toThrow(/spec/); + expect(() => validator(entity)).toThrow(/spec/); }); it('accepts unknown spec field', () => { entity.spec.unknown = 7; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); // @@ -370,97 +337,97 @@ describe('Entity', () => { it('accepts missing relations', () => { delete entity.relations; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('accepts empty relations', () => { entity.relations = []; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad relations type', () => { entity.relations = 7; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects missing relations.type', () => { delete entity.relations[0].type; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects empty relations.type', () => { entity.relations[0].type = ''; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects wrong relations.type type', () => { entity.relations[0].type = 7; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects missing relations.target', () => { delete entity.relations[0].target; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects empty relations.target', () => { entity.relations[0].target = ''; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects wrong relations.target type', () => { entity.relations[0].target = 7; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects missing relations.target.kind', () => { delete entity.relations[0].target.kind; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects empty relations.target.kind', () => { entity.relations[0].target.kind = ''; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects wrong relations.target.kind type', () => { entity.relations[0].target.kind = 7; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects missing relations.target.namespace', () => { delete entity.relations[0].target.namespace; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects empty relations.target.namespace', () => { entity.relations[0].target.namespace = ''; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects wrong relations.target.namespace type', () => { entity.relations[0].target.namespace = 7; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects missing relations.target.name', () => { delete entity.relations[0].target.name; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects empty relations.target.name', () => { entity.relations[0].target.name = ''; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects wrong relations.target.name type', () => { entity.relations[0].target.name = 7; - expect(check(entity)).toThrow(/relations/); + expect(() => validator(entity)).toThrow(/relations/); }); it('rejects unknown relation field', () => { entity.relations[0].unknown = 7; - expect(check(entity)).toThrow(/unknown/); + expect(() => validator(entity)).toThrow(/unknown/); }); // @@ -469,161 +436,161 @@ describe('Entity', () => { it('accepts missing status', () => { delete entity.status; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('accepts empty status', () => { entity.status = {}; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad status type', () => { entity.status = 7; - expect(check(entity)).toThrow(/status/); + expect(() => validator(entity)).toThrow(/status/); }); it('accepts missing status.items', () => { delete entity.status.items; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('accepts empty status.items', () => { entity.status.items = []; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad status.items type', () => { entity.status.items = 7; - expect(check(entity)).toThrow(/status/); + expect(() => validator(entity)).toThrow(/status/); }); it('rejects bad status.items item type', () => { entity.status.items[0] = 7; - expect(check(entity)).toThrow(/status/); + expect(() => validator(entity)).toThrow(/status/); }); it('rejects missing status.items.type', () => { delete entity.status.items[0].type; - expect(check(entity)).toThrow(/status/); + expect(() => validator(entity)).toThrow(/status/); }); it('rejects empty status.items.type', () => { entity.status.items[0].type = ''; - expect(check(entity)).toThrow(/status/); + expect(() => validator(entity)).toThrow(/status/); }); it('rejects bad status.items.type type', () => { entity.status.items[0].type = 7; - expect(check(entity)).toThrow(/status/); + expect(() => validator(entity)).toThrow(/status/); }); it('rejects missing status.items.level', () => { delete entity.status.items[0].level; - expect(check(entity)).toThrow(/status/); + expect(() => validator(entity)).toThrow(/status/); }); it('rejects empty status.items.level', () => { entity.status.items[0].level = ''; - expect(check(entity)).toThrow(/status/); + expect(() => validator(entity)).toThrow(/status/); }); it('rejects bad status.items.level type', () => { entity.status.items[0].level = 7; - expect(check(entity)).toThrow(/status/); + expect(() => validator(entity)).toThrow(/status/); }); it('rejects bad status.items.level enum', () => { entity.status.items[0].level = 'unknown'; - expect(check(entity)).toThrow(/status/); + expect(() => validator(entity)).toThrow(/status/); }); it('rejects missing status.items.message', () => { delete entity.status.items[0].message; - expect(check(entity)).toThrow(/status/); + expect(() => validator(entity)).toThrow(/status/); }); it('accepts empty status.items.message', () => { entity.status.items[0].message = ''; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad status.items.message type', () => { entity.status.items[0].message = 7; - expect(check(entity)).toThrow(/status/); + expect(() => validator(entity)).toThrow(/status/); }); it('accepts missing status.items.error', () => { delete entity.status.items[0].error; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects missing status.items.error.name', () => { delete entity.status.items[0].error.name; - expect(check(entity)).toThrow(/name/); + expect(() => validator(entity)).toThrow(/name/); }); it('rejects empty status.items.error.name', () => { entity.status.items[0].error.name = ''; - expect(check(entity)).toThrow(/name/); + expect(() => validator(entity)).toThrow(/name/); }); it('rejects bad status.items.error.name type', () => { entity.status.items[0].error.name = 7; - expect(check(entity)).toThrow(/name/); + expect(() => validator(entity)).toThrow(/name/); }); it('rejects missing status.items.error.message', () => { delete entity.status.items[0].error.message; - expect(check(entity)).toThrow(/message/); + expect(() => validator(entity)).toThrow(/message/); }); it('accepts empty status.items.error.message', () => { entity.status.items[0].error.message = ''; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad status.items.error.message type', () => { entity.status.items[0].error.message = 7; - expect(check(entity)).toThrow(/message/); + expect(() => validator(entity)).toThrow(/message/); }); it('accepts missing status.items.error.code', () => { delete entity.status.items[0].error.code; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('accepts empty status.items.error.code', () => { entity.status.items[0].error.code = ''; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad status.items.error.code type', () => { entity.status.items[0].error.code = 7; - expect(check(entity)).toThrow(/code/); + expect(() => validator(entity)).toThrow(/code/); }); it('accepts missing status.items.error.stack', () => { delete entity.status.items[0].error.stack; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('accepts empty status.items.error.stack', () => { entity.status.items[0].error.stack = ''; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('rejects bad status.items.error.stack type', () => { entity.status.items[0].error.stack = 7; - expect(check(entity)).toThrow(/stack/); + expect(() => validator(entity)).toThrow(/stack/); }); it('accepts unknown status.items field', () => { entity.status.items[0].unknown = 7; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); it('accepts unknown status.items.error field', () => { entity.status.items[0].error.unknown = 7; - expect(check(entity)).not.toThrow(); + expect(() => validator(entity)).not.toThrow(); }); }); diff --git a/packages/catalog-model/src/validation/entitySchemaValidator.ts b/packages/catalog-model/src/validation/entitySchemaValidator.ts new file mode 100644 index 0000000000..8a30f09d31 --- /dev/null +++ b/packages/catalog-model/src/validation/entitySchemaValidator.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2021 Spotify AB + * + * Licensed 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. + */ + +import { Schema } from 'ajv'; +import { Entity } from '../entity/Entity'; +import entitySchema from '../schema/Entity.schema.json'; +import { compileAjvSchema, throwAjvError } from './ajv'; + +/** + * Creates a validation function that takes some arbitrary data, and either + * returns that data cast to an {@link Entity} (or the given subtype) if it + * matches that schema, or throws a {@link TypeError} describing the errors. + * + * Note that this validator is only meant for applying the base schema checks; + * it does not take custom policies or additional processor based validation + * into account. + * + * By default, the plain `Entity` schema is used. If you pass in your own, it + * may contain `$ref` references to the following, which are resolved + * automatically for you: + * + * - EntityEnvelope + * - Entity + * - EntityMeta + * - common# + * + * @see https://github.com/backstage/backstage/tree/master/packages/catalog-model/src/schema + */ +export function entitySchemaValidator( + schema?: unknown, +): (data: unknown) => T { + const validate = compileAjvSchema(schema ? (schema as Schema) : entitySchema); + + return data => { + const result = validate(data); + if (result === true) { + return data as T; + } + + throw throwAjvError(validate.errors); + }; +} diff --git a/packages/catalog-model/src/validation/index.ts b/packages/catalog-model/src/validation/index.ts index d679a5323c..bdf812b4ad 100644 --- a/packages/catalog-model/src/validation/index.ts +++ b/packages/catalog-model/src/validation/index.ts @@ -15,6 +15,9 @@ */ export { CommonValidatorFunctions } from './CommonValidatorFunctions'; +export { entityEnvelopeSchemaValidator } from './entityEnvelopeSchemaValidator'; +export { entityKindSchemaValidator } from './entityKindSchemaValidator'; +export { entitySchemaValidator } from './entitySchemaValidator'; export { KubernetesValidatorFunctions } from './KubernetesValidatorFunctions'; export { makeValidator } from './makeValidator'; export type { Validators } from './types'; diff --git a/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts b/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts index 9b2d6550d1..c2c001943c 100644 --- a/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts +++ b/plugins/catalog-backend/src/next/DefaultCatalogProcessingOrchestrator.ts @@ -277,6 +277,9 @@ function createEmitter(logger: Logger, parentEntity: Entity) { return; } if (i.type === 'entity') { + // TODO(freben): Perform the most basic validation here + // (apiVersion, kind, metadata, metadata.name, metadata.namespace, spec) + const originLocation = getEntityOriginLocationRef(parentEntity); deferredEntities.push({