From 0222279021d3ed0f3d83652c36abea64a37ac9a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Mon, 21 Sep 2020 13:33:36 +0200 Subject: [PATCH] feat(freben): add handling and docs for entity references --- .../adr008-default-catalog-file-name.md | 4 +- .../adr009-entity-references.md | 69 ++++ docs/features/software-catalog/references.md | 104 +++++ microsite/sidebars.json | 4 +- mkdocs.yml | 2 + packages/catalog-model/src/entity/index.ts | 2 + packages/catalog-model/src/entity/ref.test.ts | 355 ++++++++++++++++++ packages/catalog-model/src/entity/ref.ts | 146 +++++++ packages/catalog-model/src/index.ts | 8 +- packages/catalog-model/src/types.ts | 28 ++ 10 files changed, 718 insertions(+), 4 deletions(-) create mode 100644 docs/architecture-decisions/adr009-entity-references.md create mode 100644 docs/features/software-catalog/references.md create mode 100644 packages/catalog-model/src/entity/ref.test.ts create mode 100644 packages/catalog-model/src/entity/ref.ts diff --git a/docs/architecture-decisions/adr008-default-catalog-file-name.md b/docs/architecture-decisions/adr008-default-catalog-file-name.md index c57d10c0b1..d4716318bc 100644 --- a/docs/architecture-decisions/adr008-default-catalog-file-name.md +++ b/docs/architecture-decisions/adr008-default-catalog-file-name.md @@ -7,7 +7,7 @@ description: Architecture Decision Record (ADR) log on Default Catalog File Name ## Background While the spec for the catalog file format is well described in -[ADR002](./adr002-default-catalog-file-format.md), guidance was note provided as +[ADR002](./adr002-default-catalog-file-format.md), guidance was not provided as to the name of the catalog file. Following discussion in @@ -23,4 +23,4 @@ catalog-info.yaml ``` This name is a default, **not a requirement**. The catalog file will work with -Backstage irregardless of its name. +Backstage regardless of its name. diff --git a/docs/architecture-decisions/adr009-entity-references.md b/docs/architecture-decisions/adr009-entity-references.md new file mode 100644 index 0000000000..8b7984ea49 --- /dev/null +++ b/docs/architecture-decisions/adr009-entity-references.md @@ -0,0 +1,69 @@ +--- +id: adrs-adr009 +title: ADR009: Entity References +description: Architecture Decision Record (ADR) log on Entity References +--- + +## Background + +While the spec for the catalog file format is well described in +[ADR002](./adr002-default-catalog-file-format.md), guidance was not provided as +to how one is expected to express references to other entities in the catalog. +There was also some confusion on how to reference entities in URLs in the +Backstage frontend. + +Following discussion in +[Issue 1947](https://github.com/spotify/backstage/issues/1947), a decision was +made. + +## Entity References in YAML files + +The textual format, as written by humans, to reference entities by name is on +the following form, where square brackets denote optionality: + +``` +[:][/] +``` + +That is, it is composed of between one and three parts in this specific order, +without any additional encoding, with those exact separator characters. +Optionality of `kind` and `namespace` are contextual, and they may or may not +have default contextual fallback values. + +When that format is insufficient or when machine made interchange formats wish +to express such relations in a more expressive form, a nested structure on the +following form can be used: + +```yaml +kind: +namespace: +name: +``` + +Of these, only `name` is always required. Optionality of `kind` and `namespace` +are contextual, and they may or may not have default contextual fallback values. +All other possible key values in this structure are reserved for future use. + +A system or user wanting to express a full entity name that is always valid, +shall supply the entire triplet whether using the string form or the compound +form. + +A full description of the format can be found +[in the documentation](https://backstage.io/docs/features/software-catalog/references). + +## Entity References in URLs + +Where entities are referenced by name in the Backstage frontend, the URL +containing the reference shall take the following form: + +``` +:namespace/:kind/:name +``` + +All three parts are required under all circumstances. The default value for the +`namespace` in the catalog is the string `"default"`, if the entity does not +specify one explicitly in `metadata.namespace`. + +This means that we do not encourage the string form of entity references to be +used as a single URL segment, due to the use of URL-unsafe characters leading to +possible risk, confusion, and uglier URLs. diff --git a/docs/features/software-catalog/references.md b/docs/features/software-catalog/references.md new file mode 100644 index 0000000000..5093ecc49e --- /dev/null +++ b/docs/features/software-catalog/references.md @@ -0,0 +1,104 @@ +--- +id: references +title: Entity References +description: How to express references between entities +--- + +Entities commonly have a need to reference other entities. For example, a +[Component](descriptor-format.md#kind-component) entity may want to declare who +its owner is by mentioning a Group or User entity, and a User entity may want to +declare what Group entities it is a member of. This article describes how to +write those references in your yaml entity declaration files. + +Each entity in the catalog is uniquely identified by the triplet of its +[kind](descriptor-format.md#apiversion-and-kind-required), +[namespace](descriptor-format.md#namespace-optional), and +[name](descriptor-format.md#name-required). But that's a lot to type out +manually, and in a lot of circumstances, both the kind and the namespace are +fixed, or possible to deduce, or could have sane default values. So in order to +help the writer, the catalog has a few tricks up its sleeve. + +Each reference can be expressed in one of two ways: as a compact string, or as a +compound reference structure. + +## String References + +This is the most common alternative, that should be used in almost all +circumstances. + +The string is on the form `[:][/]`, that is, it is +composed of between one and three parts in this specific order, without any +additional encoding: + +- Optionally, the kind, followed by a colon +- Optionally, the namespace, followed by a forward slash +- The name + +The name is always required. Depending on the context, you may be able to leave +out the kind and/or namespace. If you do, it is contextual what values will be +used, and the relevant documentation should specify which rule applies where. +All strings are case insensitive. + +```yaml +# Example: +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: petstore + namespace: external-systems + description: Petstore +spec: + type: service + lifecycle: experimental + owner: group:pet-managers + implementsApis: + - petstore + - internal/streetlights + - hello-world +``` + +The field `spec.owner` is a reference. In this case, the string +`group:pet-managers` was given by the user. That means that the kind is `Group`, +the namespace is left out, and the name is `pet-managers`. In this context, the +namespace was chosen to fall back to the value `default` by the code that parsed +the reference, so the end result is that we expect to find another entity in the +catalog that is of kind `Group`, namespace `default` (which, actually, also can +be left out in its own yaml file because that's the default value there too), +and name `pet-managers`. + +The entries in `implementsApis` are also references. In this case, none of them +need to specify a kind since we know from the context that that's the only kind +that's supported here. The second entry specifies a namespace but the other ones +don't, and in this context, the default is to refer to the same namespace as the +originating entity (`external-systems` here). So the three references +essentially expand to `api:external-systems/petstore`, +`api:internal/streetlights`, and `api:external-systems/hello-world`. We expect +there to exist three API kind entities in the catalog matching those references. + +## Compound References + +This is a more verbose version of a reference, where each part of the +kind-namespace-name triplet is expressed as a field in a structure. This format +can be used where necessary, such as if either of the three elements contain +colons or forward slashes. Avoid using it where possible, since it is harder to +read and write for humans. + +```yaml +# Example: +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: petstore + description: Petstore +spec: + type: service + lifecycle: experimental + owner: + kind: Group + name: aegis-imports/pet-managers +``` + +In this example, the `spec.owner` has been broken apart since the name was +complex. The kind happened to be written with an uppercase letter G, which also +works. The namespace was left out just like in the string version above, which +is handled identically. diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 8fa4935a38..ad47592dfc 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -43,6 +43,7 @@ "features/software-catalog/configuration", "features/software-catalog/system-model", "features/software-catalog/descriptor-format", + "features/software-catalog/references", "features/software-catalog/well-known-annotations", "features/software-catalog/extending-the-model", "features/software-catalog/external-integrations", @@ -158,7 +159,8 @@ "architecture-decisions/adrs-adr005", "architecture-decisions/adrs-adr006", "architecture-decisions/adrs-adr007", - "architecture-decisions/adrs-adr008" + "architecture-decisions/adrs-adr008", + "architecture-decisions/adrs-adr009" ], "Contribute": ["../CONTRIBUTING"], "Support": ["overview/support"], diff --git a/mkdocs.yml b/mkdocs.yml index ba607f8a6e..91886a9dac 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -32,6 +32,7 @@ nav: - Configuration: 'features/software-catalog/configuration.md' - System model: 'features/software-catalog/system-model.md' - YAML File Format: 'features/software-catalog/descriptor-format.md' + - Entity References: 'features/software-catalog/references.md' - Well-known Annotations: 'features/software-catalog/well-known-annotations.md' - Extending the model: 'features/software-catalog/extending-the-model.md' - External integrations: 'features/software-catalog/external-integrations.md' @@ -104,6 +105,7 @@ nav: - ADR006 - Avoid React.FC and React.SFC: 'architecture-decisions/adr006-avoid-react-fc.md' - ADR007 - Use MSW for Network Request Mocking: 'architecture-decisions/adr007-use-msw-to-mock-service-requests.md' - ADR008 - Default Catalog File Name: 'architecture-decisions/adr008-default-catalog-file-name.md' + - ADR009 - Entity References: 'architecture-decisions/adr009-entity-references.md' - Contribute: '../CONTRIBUTING.md' - Support: 'overview/support.md' - FAQ: FAQ.md diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index 380f5458cc..12ec19667b 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -17,6 +17,8 @@ export { entityMetaGeneratedFields } from './Entity'; export type { Entity, EntityMeta } from './Entity'; export * from './policies'; +export { parseEntityName, parseEntityRef, serializeEntityRef } from './ref'; +export type { EntityRefContext } from './ref'; export { entityHasChanges, generateEntityEtag, diff --git a/packages/catalog-model/src/entity/ref.test.ts b/packages/catalog-model/src/entity/ref.test.ts new file mode 100644 index 0000000000..8a8b7d3f86 --- /dev/null +++ b/packages/catalog-model/src/entity/ref.test.ts @@ -0,0 +1,355 @@ +/* + * 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. + */ + +import { parseEntityName, parseEntityRef, serializeEntityRef } from './ref'; + +describe('ref', () => { + describe('parseEntityName', () => { + it('handles some omissions', () => { + expect(parseEntityName('a:b/c')).toEqual({ + kind: 'a', + namespace: 'b', + name: 'c', + }); + expect(() => parseEntityName('b/c')).toThrow(/kind/); + expect(parseEntityName('a:c')).toEqual({ + kind: 'a', + namespace: 'default', + name: 'c', + }); + expect(() => parseEntityName('c')).toThrow(/kind/); + }); + + it('rejects bad inputs', () => { + expect(() => parseEntityName(null as any)).toThrow(); + expect(() => parseEntityName(7 as any)).toThrow(); + expect(() => parseEntityName('a:b:c')).toThrow(); + expect(() => parseEntityName('a/b/c')).toThrow(); + expect(() => parseEntityName('a/b:c')).toThrow(); + expect(() => parseEntityName('a:b/c/d')).toThrow(); + expect(() => parseEntityName('a:b/c:d')).toThrow(); + }); + + it('rejects empty parts in strings', () => { + // one is empty + expect(() => parseEntityName(':b/c')).toThrow(); + expect(() => parseEntityName('a:/c')).toThrow(); + expect(() => parseEntityName('a:b/')).toThrow(); + // two are empty + expect(() => parseEntityName('a:/')).toThrow(); + expect(() => parseEntityName(':b/')).toThrow(); + expect(() => parseEntityName(':/c')).toThrow(); + // three are empty + expect(() => parseEntityName(':/')).toThrow(); + // one is left out, one empty + expect(() => parseEntityName('/c')).toThrow(); + expect(() => parseEntityName('b/')).toThrow(); + expect(() => parseEntityName(':c')).toThrow(); + expect(() => parseEntityName('a:')).toThrow(); + // nothing at all + expect(() => parseEntityName('')).toThrow(); + }); + + it('rejects empty parts in compounds', () => { + // one is empty + expect(() => + parseEntityName({ kind: '', namespace: 'b', name: 'c' }), + ).toThrow(); + expect(() => + parseEntityName({ kind: 'a', namespace: '', name: 'c' }), + ).toThrow(); + expect(() => + parseEntityName({ kind: 'a', namespace: 'b', name: '' }), + ).toThrow(); + // two are empty + expect(() => + parseEntityName({ kind: '', namespace: '', name: 'c' }), + ).toThrow(); + expect(() => + parseEntityName({ kind: '', namespace: 'b', name: '' }), + ).toThrow(); + expect(() => + parseEntityName({ kind: 'a', namespace: '', name: '' }), + ).toThrow(); + // three are empty + expect(() => + parseEntityName({ kind: '', namespace: '', name: '' }), + ).toThrow(); + // one is left out, one empty + expect(() => parseEntityName({ namespace: '', name: 'c' })).toThrow(); + expect(() => parseEntityName({ namespace: 'b', name: '' })).toThrow(); + expect(() => parseEntityName({ kind: '', name: 'c' })).toThrow(); + expect(() => parseEntityName({ kind: 'a', name: '' })).toThrow(); + // nothing at all + expect(() => parseEntityName({} as any)).toThrow(); + }); + + it('adds defaults where necessary to strings', () => { + expect( + parseEntityName('a:b/c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'a', namespace: 'b', name: 'c' }); + expect( + parseEntityName('b/c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'x', namespace: 'b', name: 'c' }); + expect( + parseEntityName('a:c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'a', namespace: 'y', name: 'c' }); + expect(parseEntityName('a:c', { defaultKind: 'x' })).toEqual({ + kind: 'a', + namespace: 'default', + name: 'c', + }); + expect( + parseEntityName('c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'x', namespace: 'y', name: 'c' }); + expect(parseEntityName('c', { defaultKind: 'x' })).toEqual({ + kind: 'x', + namespace: 'default', + name: 'c', + }); + }); + + it('adds defaults where necessary to compounds', () => { + expect( + parseEntityName( + { kind: 'a', namespace: 'b', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'a', namespace: 'b', name: 'c' }); + expect( + parseEntityName( + { namespace: 'b', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'x', namespace: 'b', name: 'c' }); + expect( + parseEntityName( + { kind: 'a', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'a', namespace: 'y', name: 'c' }); + expect( + parseEntityName({ kind: 'a', name: 'c' }, { defaultKind: 'x' }), + ).toEqual({ kind: 'a', namespace: 'default', name: 'c' }); + expect( + parseEntityName( + { name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'x', namespace: 'y', name: 'c' }); + expect(parseEntityName({ name: 'c' }, { defaultKind: 'x' })).toEqual({ + kind: 'x', + namespace: 'default', + name: 'c', + }); + // empty strings are errors, not defaults + expect(() => + parseEntityName( + { kind: '', namespace: 'b', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toThrow(/kind/); + expect(() => + parseEntityName( + { kind: 'a', namespace: '', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toThrow(/namespace/); + }); + }); + + describe('parseEntityRef', () => { + it('handles some omissions', () => { + expect(parseEntityRef('a:b/c')).toEqual({ + kind: 'a', + namespace: 'b', + name: 'c', + }); + expect(parseEntityRef('b/c')).toEqual({ + kind: undefined, + namespace: 'b', + name: 'c', + }); + expect(parseEntityRef('a:c')).toEqual({ + kind: 'a', + namespace: undefined, + name: 'c', + }); + expect(parseEntityRef('c')).toEqual({ + kind: undefined, + namespace: undefined, + name: 'c', + }); + }); + + it('rejects bad inputs', () => { + expect(() => parseEntityRef(null as any)).toThrow(); + expect(() => parseEntityRef(7 as any)).toThrow(); + expect(() => parseEntityRef('a:b:c')).toThrow(); + expect(() => parseEntityRef('a/b/c')).toThrow(); + expect(() => parseEntityRef('a/b:c')).toThrow(); + expect(() => parseEntityRef('a:b/c/d')).toThrow(); + expect(() => parseEntityRef('a:b/c:d')).toThrow(); + }); + + it('rejects empty parts in strings', () => { + // one is empty + expect(() => parseEntityRef(':b/c')).toThrow(); + expect(() => parseEntityRef('a:/c')).toThrow(); + expect(() => parseEntityRef('a:b/')).toThrow(); + // two are empty + expect(() => parseEntityRef('a:/')).toThrow(); + expect(() => parseEntityRef(':b/')).toThrow(); + expect(() => parseEntityRef(':/c')).toThrow(); + // three are empty + expect(() => parseEntityRef(':/')).toThrow(); + // one is left out, one empty + expect(() => parseEntityRef('/c')).toThrow(); + expect(() => parseEntityRef('b/')).toThrow(); + expect(() => parseEntityRef(':c')).toThrow(); + expect(() => parseEntityRef('a:')).toThrow(); + // nothing at all + expect(() => parseEntityRef('')).toThrow(); + }); + + it('rejects empty parts in compounds', () => { + // one is empty + expect(() => + parseEntityRef({ kind: '', namespace: 'b', name: 'c' }), + ).toThrow(); + expect(() => + parseEntityRef({ kind: 'a', namespace: '', name: 'c' }), + ).toThrow(); + expect(() => + parseEntityRef({ kind: 'a', namespace: 'b', name: '' }), + ).toThrow(); + // two are empty + expect(() => + parseEntityRef({ kind: '', namespace: '', name: 'c' }), + ).toThrow(); + expect(() => + parseEntityRef({ kind: '', namespace: 'b', name: '' }), + ).toThrow(); + expect(() => + parseEntityRef({ kind: 'a', namespace: '', name: '' }), + ).toThrow(); + // three are empty + expect(() => + parseEntityRef({ kind: '', namespace: '', name: '' }), + ).toThrow(); + // one is left out, one empty + expect(() => parseEntityRef({ namespace: '', name: 'c' })).toThrow(); + expect(() => parseEntityRef({ namespace: 'b', name: '' })).toThrow(); + expect(() => parseEntityRef({ kind: '', name: 'c' })).toThrow(); + expect(() => parseEntityRef({ kind: 'a', name: '' })).toThrow(); + // nothing at all + expect(() => parseEntityRef({} as any)).toThrow(); + }); + + it('adds defaults where necessary to strings', () => { + expect( + parseEntityRef('a:b/c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'a', namespace: 'b', name: 'c' }); + expect( + parseEntityRef('b/c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'x', namespace: 'b', name: 'c' }); + expect( + parseEntityRef('a:c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'a', namespace: 'y', name: 'c' }); + expect( + parseEntityRef('c', { defaultKind: 'x', defaultNamespace: 'y' }), + ).toEqual({ kind: 'x', namespace: 'y', name: 'c' }); + }); + + it('adds defaults where necessary to compounds', () => { + expect( + parseEntityRef( + { kind: 'a', namespace: 'b', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'a', namespace: 'b', name: 'c' }); + expect( + parseEntityRef( + { namespace: 'b', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'x', namespace: 'b', name: 'c' }); + expect( + parseEntityRef( + { kind: 'a', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'a', namespace: 'y', name: 'c' }); + expect( + parseEntityRef( + { name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toEqual({ kind: 'x', namespace: 'y', name: 'c' }); + // empty strings are errors, not defaults + expect(() => + parseEntityRef( + { kind: '', namespace: 'b', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toThrow(/kind/); + expect(() => + parseEntityRef( + { kind: 'a', namespace: '', name: 'c' }, + { defaultKind: 'x', defaultNamespace: 'y' }, + ), + ).toThrow(/namespace/); + }); + }); + + describe('serializeEntityRef', () => { + it('handles partials', () => { + expect( + serializeEntityRef({ kind: 'a', namespace: 'b', name: 'c' }), + ).toEqual('a:b/c'); + expect(serializeEntityRef({ namespace: 'b', name: 'c' })).toEqual('b/c'); + expect(serializeEntityRef({ kind: 'a', name: 'c' })).toEqual('a:c'); + expect(serializeEntityRef({ name: 'c' })).toEqual('c'); + }); + + it('picks the least complex form', () => { + expect( + serializeEntityRef({ kind: 'a', namespace: 'b', name: 'c' }), + ).toEqual('a:b/c'); + expect(serializeEntityRef({ namespace: 'b', name: 'c' })).toEqual('b/c'); + expect(serializeEntityRef({ kind: 'a', name: 'c' })).toEqual('a:c'); + expect(serializeEntityRef({ name: 'c' })).toEqual('c'); + expect( + serializeEntityRef({ kind: 'a:x', namespace: 'b', name: 'c' }), + ).toEqual({ kind: 'a:x', namespace: 'b', name: 'c' }); + expect( + serializeEntityRef({ kind: 'a/x', namespace: 'b', name: 'c' }), + ).toEqual({ kind: 'a/x', namespace: 'b', name: 'c' }); + expect( + serializeEntityRef({ kind: 'a', namespace: 'b:x', name: 'c' }), + ).toEqual({ kind: 'a', namespace: 'b:x', name: 'c' }); + expect( + serializeEntityRef({ kind: 'a', namespace: 'b/x', name: 'c' }), + ).toEqual({ kind: 'a', namespace: 'b/x', name: 'c' }); + expect( + serializeEntityRef({ kind: 'a', namespace: 'b', name: 'c:x' }), + ).toEqual({ kind: 'a', namespace: 'b', name: 'c:x' }); + expect( + serializeEntityRef({ kind: 'a', namespace: 'b', name: 'c/x' }), + ).toEqual({ kind: 'a', namespace: 'b', name: 'c/x' }); + }); + }); +}); diff --git a/packages/catalog-model/src/entity/ref.ts b/packages/catalog-model/src/entity/ref.ts new file mode 100644 index 0000000000..1a579eeb7e --- /dev/null +++ b/packages/catalog-model/src/entity/ref.ts @@ -0,0 +1,146 @@ +/* + * 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. + */ + +import { CompoundEntityRef, EntityName, EntityRef } from '../types'; + +/** + * The context of defaults that entity reference parsing happens within. + */ +export type EntityRefContext = { + /** The default kind, if none is given in the reference */ + defaultKind?: string; + /** The default namespace, if none is given in the reference */ + defaultNamespace?: string; +}; + +/** + * Parses an entity reference, either on string or compound form, and always + * returns a complete entity name including kind, namespace and name. + * + * This function automatically assumes the default namespace "default" unless + * otherwise specified as part of the options, and will throw an error if no + * kind was specified in the input reference and no default kind was given. + * + * @param ref The reference to parse + * @param context The context of defaults that the parsing happens within + * @returns A complete entity name + */ +export function parseEntityName( + ref: EntityRef, + context: EntityRefContext = {}, +): EntityName { + const { kind, namespace, name } = parseEntityRef(ref, { + defaultNamespace: 'default', + ...context, + }); + + if (!kind) { + throw new Error( + `Entity reference ${namespace}/${name} did not contain a kind`, + ); + } + + // This is a corner case - when the ref contained a namespace but it was the + // empty string (so the default did not kick in) + if (!namespace) { + throw new Error( + `Entity reference ${kind}:${name} did not contain a namespace`, + ); + } + + return { + kind, + namespace: namespace!, + name, + }; +} + +/** + * Parses an entity reference, either on string or compound form, and returns + * a structure with a name, and optional kind and namespace. + * + * The options object can contain default values for the kind and namespace, + * that will be used if the input reference did not specify any. + * + * @param ref The reference to parse + * @param context The context of defaults that the parsing happens within + * @returns The compound form of the reference + */ +export function parseEntityRef( + ref: EntityRef, + context: EntityRefContext = {}, +): CompoundEntityRef { + if (!ref) { + throw new Error(`Entity reference must not be empty`); + } + + if (typeof ref === 'string') { + const match = /^(?:([^:/]+):)?(?:([^:/]+)\/)?([^:/]+)$/.exec(ref.trim()); + if (!match) { + throw new Error( + `Entity reference "${ref}" was not on the form [:][/]`, + ); + } + + return { + kind: match[1] ?? context.defaultKind, + namespace: match[2] ?? context.defaultNamespace, + name: match[3], + }; + } + + const { kind, namespace, name } = ref; + if (kind === '') { + throw new Error('Entity reference kinds must not be empty'); + } else if (namespace === '') { + throw new Error('Entity reference namespaces must not be empty'); + } else if (!name) { + throw new Error('Entity references must contain a name'); + } + + return { + kind: kind ?? context.defaultKind, + namespace: namespace ?? context.defaultNamespace, + name, + }; +} + +/** + * Takes an entity reference or name, and outputs an entity reference on the + * most compact form possible. I.e. if the parts do not contain any + * special/reserved characters, it outputs the string form, otherwise it + * outputs the compound form. + * + * @param ref The reference to serialize + * @returns The same reference on either string or compound form + */ +export function serializeEntityRef( + ref: CompoundEntityRef | EntityName, +): EntityRef { + const { kind, namespace, name } = ref; + if ( + kind?.includes(':') || + kind?.includes('/') || + namespace?.includes(':') || + namespace?.includes('/') || + name.includes(':') || + name.includes('/') + ) { + return { kind, namespace, name }; + } + + return `${kind ? `${kind}:` : ''}${namespace ? `${namespace}/` : ''}${name}`; +} diff --git a/packages/catalog-model/src/index.ts b/packages/catalog-model/src/index.ts index 9469319b28..16e7e3477b 100644 --- a/packages/catalog-model/src/index.ts +++ b/packages/catalog-model/src/index.ts @@ -18,5 +18,11 @@ export * from './entity'; export { EntityPolicies } from './EntityPolicies'; export * from './kinds'; export * from './location'; -export type { EntityPolicy, JSONSchema } from './types'; +export type { + CompoundEntityRef, + EntityName, + EntityPolicy, + EntityRef, + JSONSchema, +} from './types'; export * from './validation'; diff --git a/packages/catalog-model/src/types.ts b/packages/catalog-model/src/types.ts index cba6438ccb..1e0a778827 100644 --- a/packages/catalog-model/src/types.ts +++ b/packages/catalog-model/src/types.ts @@ -34,3 +34,31 @@ export type EntityPolicy = { }; export type JSONSchema = JSONSchema7 & { [key in string]?: JsonValue }; + +/** + * A complete entity name, with the full kind-namespace-name triplet. + */ +export type EntityName = { + kind: string; + namespace: string; + name: string; +}; + +/** + * A reference by name to an entity, where the kind and/or the namespace can be + * left out. + * + * Left-out parts of the reference need to be handled by the application, + * either by rejecting the reference or by falling back to default values. + */ +export type CompoundEntityRef = { + kind?: string; + namespace?: string; + name: string; +}; + +/** + * A reference by name to an entity, either as a compact string representation, + * or as a compound reference structure. + */ +export type EntityRef = string | CompoundEntityRef;