Merge pull request #2532 from spotify/freben/ref
feat(freben): add handling and docs for entity references
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
```
|
||||
[<kind>:][<namespace>/]<name>
|
||||
```
|
||||
|
||||
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: <kind>
|
||||
namespace: <namespace>
|
||||
name: <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.
|
||||
@@ -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 `[<kind>:][<namespace>/]<name>`, 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.
|
||||
@@ -42,6 +42,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"],
|
||||
|
||||
@@ -31,6 +31,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
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
export { entityMetaGeneratedFields } from './Entity';
|
||||
export type { Entity, EntityMeta } from './Entity';
|
||||
export * from './policies';
|
||||
export { parseEntityName, serializeEntityRef } from './ref';
|
||||
export {
|
||||
entityHasChanges,
|
||||
generateEntityEtag,
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
/*
|
||||
* 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({ namespace: 'b', name: 'c' })).toThrow();
|
||||
expect(() =>
|
||||
parseEntityName({ kind: 'a', namespace: '', name: 'c' }),
|
||||
).toThrow();
|
||||
expect(() => parseEntityName({ kind: 'a', name: 'c' })).not.toThrow();
|
||||
expect(() =>
|
||||
parseEntityName({ kind: 'a', namespace: 'b', name: '' }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseEntityName({ kind: 'a', namespace: 'b' } as any),
|
||||
).toThrow();
|
||||
// two are empty
|
||||
expect(() =>
|
||||
parseEntityName({ kind: '', namespace: '', name: 'c' }),
|
||||
).toThrow();
|
||||
expect(() => parseEntityName({ name: 'c' })).toThrow();
|
||||
expect(() =>
|
||||
parseEntityName({ kind: '', namespace: 'b', name: '' }),
|
||||
).toThrow();
|
||||
expect(() => parseEntityName({ namespace: 'b' } as any)).toThrow();
|
||||
expect(() =>
|
||||
parseEntityName({ kind: 'a', namespace: '', name: '' }),
|
||||
).toThrow();
|
||||
expect(() => parseEntityName({ kind: 'a' } as any)).toThrow();
|
||||
// three are empty
|
||||
expect(() =>
|
||||
parseEntityName({ kind: '', namespace: '', name: '' }),
|
||||
).toThrow();
|
||||
expect(() => parseEntityName({} as any)).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();
|
||||
});
|
||||
|
||||
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' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* 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 { EntityName, EntityRef } from '../types';
|
||||
|
||||
/**
|
||||
* The context of defaults that entity reference parsing happens within.
|
||||
*/
|
||||
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`,
|
||||
);
|
||||
}
|
||||
|
||||
return { kind, 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?: { defaultKind: string },
|
||||
): {
|
||||
kind: string;
|
||||
namespace?: string;
|
||||
name: string;
|
||||
};
|
||||
export function parseEntityRef(
|
||||
ref: EntityRef,
|
||||
context?: { defaultNamespace: string },
|
||||
): {
|
||||
kind?: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
};
|
||||
export function parseEntityRef(
|
||||
ref: EntityRef,
|
||||
context?: { defaultKind: string; defaultNamespace: string },
|
||||
): {
|
||||
kind: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
};
|
||||
export function parseEntityRef(
|
||||
ref: EntityRef,
|
||||
context: EntityRefContext = {},
|
||||
): {
|
||||
kind?: string;
|
||||
namespace?: string;
|
||||
name: string;
|
||||
} {
|
||||
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 [<kind>:][<namespace>/]<name>`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
kind: match[1]?.slice(0, -1) ?? context.defaultKind,
|
||||
namespace: match[2]?.slice(0, -1) ?? 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: {
|
||||
kind?: string;
|
||||
namespace?: string;
|
||||
name: string;
|
||||
}): 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}`;
|
||||
}
|
||||
@@ -18,5 +18,5 @@ export * from './entity';
|
||||
export { EntityPolicies } from './EntityPolicies';
|
||||
export * from './kinds';
|
||||
export * from './location';
|
||||
export type { EntityPolicy, JSONSchema } from './types';
|
||||
export type { EntityName, EntityPolicy, EntityRef, JSONSchema } from './types';
|
||||
export * from './validation';
|
||||
|
||||
@@ -34,3 +34,29 @@ 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, either as a compact string representation,
|
||||
* or as a compound reference structure.
|
||||
*
|
||||
* The string representation is on the form [<kind>:][<namespace>/]<name>.
|
||||
*
|
||||
* 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 EntityRef =
|
||||
| string
|
||||
| {
|
||||
kind?: string;
|
||||
namespace?: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user