Refactor entity validation in catalog-model

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2021-05-25 22:01:07 +02:00
parent 7947a407f6
commit 704875e26d
30 changed files with 982 additions and 429 deletions
+14
View File
@@ -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.
+96 -94
View File
@@ -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
+40 -24
View File
@@ -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<T extends EntityEnvelope = EntityEnvelope>(schema?: unknown): (data: unknown) => T;
// @public
export function entityHasChanges(previous: Entity, next: Entity): boolean;
// @public
export function entityKindSchemaValidator<T extends Entity>(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<T extends Entity = Entity>(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<any>): 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?: {
+1
View File
@@ -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"
+3 -2
View File
@@ -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.
@@ -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;
};
};
@@ -27,6 +27,7 @@ export type {
EntityRelation,
EntityRelationSpec,
} from './Entity';
export type { EntityEnvelope } from './EntityEnvelope';
export type {
UNSTABLE_EntityStatus,
UNSTABLE_EntityStatusItem,
@@ -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],
);
@@ -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],
);
@@ -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],
);
@@ -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],
);
@@ -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],
);
@@ -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],
);
@@ -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],
);
@@ -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],
);
@@ -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],
);
@@ -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],
);
+1 -2
View File
@@ -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,
+9 -57
View File
@@ -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<any>,
): 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 || '<root>'} ${error.message}${
error.params
? ` - ${Object.entries(error.params)
.map(([key, val]) => `${key}: ${val}`)
.join(', ')}`
: ''
}`,
);
async check(data) {
return validator(data) === data;
},
};
}
@@ -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",
@@ -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
}
}
}
}
}
@@ -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<unknown, ValidateFunction<unknown>>();
// The core schemas that others can depend on
const refDependencyCandidates = [
entityEnvelopeSchema,
entitySchema,
entityMetaSchema,
commonSchema,
];
export function throwAjvError(
errors: ValidateFunction<unknown>['errors'],
): never {
if (!errors?.length) {
throw new TypeError('Unknown error');
}
const error = errors[0];
throw new TypeError(
`${error.dataPath || '<root>'} ${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<unknown> {
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<string>();
if (schema.$id) {
seen.add(schema.$id);
}
const selected = new Array<Schema>();
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<string> {
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);
}
}
}
}
}
@@ -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();
});
});
@@ -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#<id>
*
* @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);
};
}
@@ -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/);
});
});
@@ -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"`:
*
* <pre>
* "apiVersion": {
* "enum": ["backstage.io/v1alpha1", "backstage.io/v1beta1"]
* },
* "kind": {
* "enum": ["Group"]
* },
* </pre>
*
* 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#<id>
*
* @see https://github.com/backstage/backstage/tree/master/packages/catalog-model/src/schema
*/
export function entityKindSchemaValidator<T extends Entity>(
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);
};
}
@@ -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 || '<root>'} ${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();
});
});
@@ -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#<id>
*
* @see https://github.com/backstage/backstage/tree/master/packages/catalog-model/src/schema
*/
export function entitySchemaValidator<T extends Entity = Entity>(
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);
};
}
@@ -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';
@@ -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({