Merge pull request #4279 from andrewthauer/feat/entity-links

feat: add entity metadata links
This commit is contained in:
Andrew Thauer
2021-01-28 13:15:32 -05:00
committed by GitHub
17 changed files with 329 additions and 1 deletions
+5
View File
@@ -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;
}
}