Merge pull request #4279 from andrewthauer/feat/entity-links
feat: add entity metadata links
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/catalog-model': patch
|
||||
---
|
||||
|
||||
Adds a new optional `links` metadata field to the Entity class within the `catalog-model` package (as discussed in [[RFC] Entity Links](https://github.com/backstage/backstage/issues/3787)). This PR adds support for the entity links only. Follow up PR's will introduce the UI component to display them.
|
||||
@@ -54,6 +54,11 @@ software catalog API.
|
||||
"labels": {
|
||||
"system": "public-websites"
|
||||
},
|
||||
"links": [{
|
||||
"url": "https://admin.example-org.com",
|
||||
"title": "Admin Dashboard",
|
||||
"icon": "dashboard"
|
||||
}],
|
||||
"tags": ["java"],
|
||||
"name": "artist-web",
|
||||
"uid": "2152f463-549d-4d8d-a94d-ce2b7676c6e2"
|
||||
@@ -81,6 +86,10 @@ metadata:
|
||||
circleci.com/project-slug: github/example-org/artist-website
|
||||
tags:
|
||||
- java
|
||||
links:
|
||||
- url: https://admin.example-org.com
|
||||
title: Admin Dashboard
|
||||
icon: dashboard
|
||||
spec:
|
||||
type: website
|
||||
lifecycle: production
|
||||
@@ -314,6 +323,34 @@ This field is optional, and currently has no special semantics.
|
||||
Each tag must be sequences of `[a-z0-9]` separated by `-`, at most 63 characters
|
||||
in total.
|
||||
|
||||
### `links` [optional]
|
||||
|
||||
A list of external hyperlinks related to the entity. Links can provide
|
||||
additional contextual information that may be located outside of Backstage
|
||||
itself. For example, an admin dashboard or external CMS page.
|
||||
|
||||
Users may add links to descriptor YAML files to provide additional reference
|
||||
information to external content & resources. Links are not intended to drive any
|
||||
additional functionality within Backstage, which is best left to `annotations`
|
||||
and `labels`. It is recommended to use links only when an equivalent well-known
|
||||
`annotation` does not cover a similar use case.
|
||||
|
||||
Fields of a link are:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ------- | ------ | ------------------------------------------------------------------------------------ |
|
||||
| `url` | String | [Required] A `url` in a standard `uri` format (e.g. `https://example.com/some/page`) |
|
||||
| `title` | String | [Optional] A user friendly display name for the link. |
|
||||
| `icon` | String | [Optional] A key representing a visual icon to be displayed in the UI. |
|
||||
|
||||
_NOTE_: The `icon` field value is meant to be a semantic key that will map to a
|
||||
specific icon that may be provided by an icon library (e.g. `material-ui`
|
||||
icons). These keys should be a sequence of `[a-z0-9A-Z]`, possibly separated by
|
||||
one of `[-_.]`. Backstage may support some basic icons out of the box, but the
|
||||
Backstage integrator will ultimately be left to provide the appropriate icon
|
||||
component mappings. A generic fallback icon would be provided if a mapping
|
||||
cannot be resolved.
|
||||
|
||||
## Common to All Kinds: Relations
|
||||
|
||||
The `relations` root field is a read-only list of relations, between the current
|
||||
|
||||
@@ -3,6 +3,11 @@ kind: Group
|
||||
metadata:
|
||||
name: acme-corp
|
||||
description: The acme-corp organization
|
||||
links:
|
||||
- url: http://www.acme.com/
|
||||
title: Website
|
||||
- url: https://meta.wikimedia.org/wiki/
|
||||
title: Intranet
|
||||
spec:
|
||||
type: organization
|
||||
profile:
|
||||
|
||||
@@ -6,6 +6,13 @@ metadata:
|
||||
tags:
|
||||
- store
|
||||
- rest
|
||||
links:
|
||||
- url: https://github.com/swagger-api/swagger-petstore
|
||||
title: GitHub Repo
|
||||
icon: github
|
||||
- url: https://github.com/OAI/OpenAPI-Specification/blob/master/examples/v3.0/petstore.yaml
|
||||
title: API Spec
|
||||
icon: code
|
||||
spec:
|
||||
type: openapi
|
||||
lifecycle: experimental
|
||||
|
||||
@@ -5,6 +5,10 @@ metadata:
|
||||
description: The Smartylighting Streetlights API allows you to remotely manage the city lights.
|
||||
tags:
|
||||
- mqtt
|
||||
links:
|
||||
- url: https://github.com/asyncapi/asyncapi/blob/master/examples/1.2.0/streetlights.yml
|
||||
title: Source Code
|
||||
icon: code
|
||||
spec:
|
||||
type: asyncapi
|
||||
lifecycle: production
|
||||
|
||||
@@ -3,6 +3,10 @@ kind: API
|
||||
metadata:
|
||||
name: starwars-graphql
|
||||
description: SWAPI GraphQL Schema
|
||||
links:
|
||||
- url: https://github.com/graphql/swapi-graphql
|
||||
title: GitHub Repo
|
||||
icon: github
|
||||
spec:
|
||||
type: graphql
|
||||
lifecycle: production
|
||||
|
||||
@@ -6,6 +6,12 @@ metadata:
|
||||
tags:
|
||||
- java
|
||||
- data
|
||||
links:
|
||||
- url: https://example.com/apm/artists-lookup
|
||||
title: APM
|
||||
icon: dashboard
|
||||
- url: https://example.com/logs/artists-lookup
|
||||
title: Logs
|
||||
spec:
|
||||
type: service
|
||||
lifecycle: experimental
|
||||
|
||||
@@ -3,6 +3,10 @@ kind: Component
|
||||
metadata:
|
||||
name: petstore
|
||||
description: Petstore
|
||||
links:
|
||||
- url: https://github.com/swagger-api/swagger-petstore
|
||||
title: GitHub Repo
|
||||
icon: github
|
||||
spec:
|
||||
type: service
|
||||
lifecycle: experimental
|
||||
|
||||
@@ -3,5 +3,11 @@ kind: Domain
|
||||
metadata:
|
||||
name: artists
|
||||
description: Everything related to artists
|
||||
links:
|
||||
- url: http://example.com/domain/artists/
|
||||
title: Domain Readme
|
||||
- url: http://example.com/domains/artists/dashboard
|
||||
title: Domain Metrics Dashboard
|
||||
icon: dashboard
|
||||
spec:
|
||||
owner: team-a
|
||||
|
||||
@@ -125,6 +125,11 @@ export type EntityMeta = JsonObject & {
|
||||
* various ways.
|
||||
*/
|
||||
tags?: string[];
|
||||
|
||||
/**
|
||||
* A list of external hyperlinks related to the entity.
|
||||
*/
|
||||
links?: EntityLink[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -161,3 +166,23 @@ export type EntityRelationSpec = {
|
||||
*/
|
||||
target: EntityName;
|
||||
};
|
||||
|
||||
/**
|
||||
* A link to external information that is related to the entity.
|
||||
*/
|
||||
export type EntityLink = {
|
||||
/**
|
||||
* The url to the external site, document, etc.
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* An optional descriptive title for the link.
|
||||
*/
|
||||
title?: string;
|
||||
|
||||
/**
|
||||
* An optional semantic key that represents a visual icon.
|
||||
*/
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
} from './constants';
|
||||
export type {
|
||||
Entity,
|
||||
EntityLink,
|
||||
EntityMeta,
|
||||
EntityRelation,
|
||||
EntityRelationSpec,
|
||||
|
||||
@@ -38,6 +38,10 @@ describe('FieldFormatEntityPolicy', () => {
|
||||
tags:
|
||||
- java
|
||||
- data-service
|
||||
links:
|
||||
- url: https://example.org
|
||||
title: Website
|
||||
icon: website
|
||||
spec:
|
||||
custom: stuff
|
||||
`);
|
||||
@@ -110,4 +114,110 @@ describe('FieldFormatEntityPolicy', () => {
|
||||
data.metadata.tags.push('Hello World');
|
||||
await expect(policy.enforce(data)).rejects.toThrow(/tags.*"Hello World"/i);
|
||||
});
|
||||
|
||||
it('accepts missing links', async () => {
|
||||
delete data.metadata.links;
|
||||
await expect(policy.enforce(data)).resolves.toBe(data);
|
||||
});
|
||||
|
||||
it('accepts empty links array', async () => {
|
||||
data.metadata.links = [];
|
||||
await expect(policy.enforce(data)).resolves.toBe(data);
|
||||
});
|
||||
|
||||
it('accepts multiple links', async () => {
|
||||
data.metadata.links = [{ url: 'http://foo' }, { url: 'https://bar' }];
|
||||
await expect(policy.enforce(data)).resolves.toBe(data);
|
||||
});
|
||||
|
||||
it('rejects missing link url value', async () => {
|
||||
data.metadata.links = [{}];
|
||||
await expect(policy.enforce(data)).rejects.toThrow(/links.0.url/i);
|
||||
});
|
||||
|
||||
it('rejects a single bad missing link url value', async () => {
|
||||
data.metadata.links = [{ url: 'http://good' }, { url: '' }];
|
||||
await expect(policy.enforce(data)).rejects.toThrow(
|
||||
/links.1.url.*valid url/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty link url value', async () => {
|
||||
data.metadata.links = [{ url: '' }];
|
||||
await expect(policy.enforce(data)).rejects.toThrow(/links.0.url.*/i);
|
||||
});
|
||||
|
||||
it('rejects bad link url value', async () => {
|
||||
data.metadata.links = [{ url: 'invalid' }];
|
||||
await expect(policy.enforce(data)).rejects.toThrow(
|
||||
/links.0.url.*"invalid"/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts missing link title', async () => {
|
||||
data.metadata.links = [{ url: 'http://foo', icon: 'dashboard' }];
|
||||
await expect(policy.enforce(data)).resolves.toBe(data);
|
||||
});
|
||||
|
||||
it('rejects empty link title', async () => {
|
||||
data.metadata.links = [{ url: 'http://foo', title: '' }];
|
||||
await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*""/i);
|
||||
});
|
||||
|
||||
it('rejects bad link title', async () => {
|
||||
data.metadata.links = [{ url: 'http://foo', title: 123 }];
|
||||
await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*"123"/i);
|
||||
});
|
||||
|
||||
it.each([[123], [{}], [[]]])(
|
||||
'rejects bad link title %s',
|
||||
async (title: unknown) => {
|
||||
data.metadata.links = [{ url: 'http://foo', title }];
|
||||
await expect(policy.enforce(data)).rejects.toThrow(/links.0.title.*/i);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a single bad link title', async () => {
|
||||
data.metadata.links = [
|
||||
{ url: 'http://foo', title: 'good' },
|
||||
{ url: 'http://foo', title: '' },
|
||||
];
|
||||
await expect(policy.enforce(data)).rejects.toThrow(/links.1.title.*""/i);
|
||||
});
|
||||
|
||||
it('accepts missing link icon', async () => {
|
||||
data.metadata.links = [{ url: 'http://foo', title: 'foo' }];
|
||||
await expect(policy.enforce(data)).resolves.toBe(data);
|
||||
});
|
||||
|
||||
it('rejects empty link icon', async () => {
|
||||
data.metadata.links = [{ url: 'http://foo', icon: '' }];
|
||||
await expect(policy.enforce(data)).rejects.toThrow(/links.0.icon.*""/i);
|
||||
});
|
||||
|
||||
it.each([['dashboard'], ['admin-dashboard'], ['foo_dashboard']])(
|
||||
'accepts valid link icon',
|
||||
async icon => {
|
||||
data.metadata.links = [{ url: 'http://foo', icon }];
|
||||
await expect(policy.enforce(data)).resolves.toBe(data);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([[123], [{}], [[]], ['abc xyz']])(
|
||||
'rejects bad link icon value %s',
|
||||
async (icon: unknown) => {
|
||||
data.metadata.links = [{ url: 'http://foo', icon }];
|
||||
await expect(policy.enforce(data)).rejects.toThrow(/links.0.icon.*/i);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects a single bad link icon value', async () => {
|
||||
data.metadata.links = [
|
||||
{ url: 'http://foo', icon: 'good' },
|
||||
{ url: 'http://foo', icon: 'not good' },
|
||||
];
|
||||
await expect(policy.enforce(data)).rejects.toThrow(
|
||||
/links.1.icon.*"not good"/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,6 +83,12 @@ export class FieldFormatEntityPolicy implements EntityPolicy {
|
||||
expectation =
|
||||
'a string that is a sequence of [a-zA-Z][a-z0-9A-Z], at most 63 characters in total';
|
||||
break;
|
||||
case 'isValidUrl':
|
||||
expectation = 'a string that is a valid url';
|
||||
break;
|
||||
case 'isValidString':
|
||||
expectation = 'a non empty string';
|
||||
break;
|
||||
default:
|
||||
expectation = undefined;
|
||||
break;
|
||||
@@ -134,6 +140,23 @@ export class FieldFormatEntityPolicy implements EntityPolicy {
|
||||
require(`tags.${i}`, tags[i], this.validators.isValidTag);
|
||||
}
|
||||
|
||||
const links = entity.metadata.links ?? [];
|
||||
|
||||
for (let i = 0; i < links.length; ++i) {
|
||||
require(`links.${i}.url`, links[i]
|
||||
?.url, CommonValidatorFunctions.isValidUrl);
|
||||
optional(
|
||||
`links.${i}.title`,
|
||||
links[i]?.title,
|
||||
CommonValidatorFunctions.isValidString,
|
||||
);
|
||||
optional(
|
||||
`links.${i}.icon`,
|
||||
links[i]?.icon,
|
||||
KubernetesValidatorFunctions.isValidObjectName,
|
||||
);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ describe('SchemaValidEntityPolicy', () => {
|
||||
tags:
|
||||
- java
|
||||
- data
|
||||
links:
|
||||
- url: https://example.com
|
||||
title: Website
|
||||
icon: website
|
||||
spec:
|
||||
custom: stuff
|
||||
`);
|
||||
@@ -198,6 +202,24 @@ describe('SchemaValidEntityPolicy', () => {
|
||||
await expect(policy.enforce(data)).rejects.toThrow(/tags/);
|
||||
});
|
||||
|
||||
it('accepts missing links', async () => {
|
||||
delete data.metadata.links;
|
||||
await expect(policy.enforce(data)).resolves.toBe(data);
|
||||
});
|
||||
|
||||
it('accepts empty links array', async () => {
|
||||
data.metadata.links = [];
|
||||
await expect(policy.enforce(data)).resolves.toBe(data);
|
||||
});
|
||||
|
||||
it.each([['invalid type'], [123], [{}], [{ url: 'https://foo' }]])(
|
||||
'rejects bad links type %s',
|
||||
async (val: unknown) => {
|
||||
data.metadata.links = val;
|
||||
await expect(policy.enforce(data)).rejects.toThrow(/links/);
|
||||
},
|
||||
);
|
||||
|
||||
//
|
||||
// spec
|
||||
//
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import * as yup from 'yup';
|
||||
import { EntityPolicy } from '../../types';
|
||||
import { Entity } from '../Entity';
|
||||
import { Entity, EntityLink } from '../Entity';
|
||||
|
||||
const DEFAULT_ENTITY_SCHEMA = yup
|
||||
.object({
|
||||
@@ -33,6 +33,7 @@ const DEFAULT_ENTITY_SCHEMA = yup
|
||||
labels: yup.object<Record<string, string>>().notRequired(),
|
||||
annotations: yup.object<Record<string, string>>().notRequired(),
|
||||
tags: yup.array<string>().notRequired(),
|
||||
links: yup.array<EntityLink>().notRequired(),
|
||||
})
|
||||
.required(),
|
||||
spec: yup.object({}).notRequired(),
|
||||
|
||||
@@ -161,4 +161,44 @@ describe('CommonValidatorFunctions', () => {
|
||||
])(`isValidDnsLabel %p ? %p`, (value, result) => {
|
||||
expect(CommonValidatorFunctions.isValidDnsLabel(value)).toBe(result);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, false],
|
||||
[7, false],
|
||||
['', false],
|
||||
['abc', false],
|
||||
[' abc', false],
|
||||
['', false],
|
||||
[{}, false],
|
||||
['http://foo', true],
|
||||
['https://www.foo.com/', true],
|
||||
['https://foo.com:8080', true],
|
||||
['https://foo:8080/page', true],
|
||||
['https://foo:8080/sub/page', true],
|
||||
['https://foo:8080/sub/page?query', true],
|
||||
['https://foo:8080/sub/page/?query=value', true],
|
||||
['https://foo:8080/sub/page/?query=value&', true],
|
||||
['https://foo:8080/sub/page/?query=value&another=val', true],
|
||||
['https://foo.com/page#fragment', true],
|
||||
['ftp://ftp.some.domain.com/path', true],
|
||||
['xyz://custom-protocol:4444/path', true],
|
||||
])(`isValidUrl %p ? %p`, (value, result) => {
|
||||
expect(CommonValidatorFunctions.isValidUrl(value)).toBe(result);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[null, false],
|
||||
[true, false],
|
||||
[7, false],
|
||||
[{}, false],
|
||||
['', false],
|
||||
[' ', false],
|
||||
[' ', false],
|
||||
['abc', true],
|
||||
[' abc ', true],
|
||||
['abc xyz', true],
|
||||
['abc xyz abc.', true],
|
||||
])(`isValidString %p ? %p`, (value, result) => {
|
||||
expect(CommonValidatorFunctions.isValidString(value)).toBe(result);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,4 +92,32 @@ export class CommonValidatorFunctions {
|
||||
/^[a-z0-9]+(\-[a-z0-9]+)*$/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the value is a valid URL.
|
||||
*
|
||||
* @param value The value to check
|
||||
*/
|
||||
static isValidUrl(value: unknown): boolean {
|
||||
if (typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the value is a non empty string value.
|
||||
*
|
||||
* @param value The value to check
|
||||
*/
|
||||
static isValidString(value: unknown): boolean {
|
||||
return typeof value === 'string' && value?.trim()?.length >= 1;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user