diff --git a/.changeset/techdocs-twenty-donuts-eat.md b/.changeset/techdocs-twenty-donuts-eat.md index b4f9a067c3..a95ec5a752 100644 --- a/.changeset/techdocs-twenty-donuts-eat.md +++ b/.changeset/techdocs-twenty-donuts-eat.md @@ -3,22 +3,21 @@ '@backstage/plugin-techdocs-backend': minor --- -Introduce the annotation `backstage.io/techdocs-ref: ` as an alias for `backstage.io/techdocs-ref: dir:`. -This annotation works with both the basic and the recommended flow, however, it will be most useful with the basic approach. +Improve the annotation `backstage.io/techdocs-ref: dir:` that links to a path that is relative to the source of the annotated entity. +This annotation works with the basic and the recommended flow, however, it will be most useful with the basic approach. +This change remove the deprecation of the `dir` reference and provides first-class support for it. In addition, this change removes the support of the deprecated `github`, `gitlab`, and `azure/api` locations from the `dir` reference preparer. #### Example Usage -The new annotation is convenient if the documentation is stored in the same location, i.e. the same git repository, as the `catalog-info.yaml`. +The annotation is convenient if the documentation is stored in the same location, i.e. the same git repository, as the `catalog-info.yaml`. While it is still supported to add full URLs such as `backstage.io/techdocs-ref: url:https://...` for custom setups, documentation is mostly stored in the same repository as the entity definition. By automatically resolving the target relative to the registration location of the entity, the configuration overhead for this default setup is minimized. Since it leverages the `@backstage/integrations` package for the URL resolution, this is compatible with every supported source. Consider the following examples: -> Note that the short version `` is only an alias for the still supported `dir:`. - 1. "I have a repository with a single `catalog-info.yaml` and a TechDocs page in the root folder!" ``` @@ -29,7 +28,7 @@ https://github.com/backstage/example/tree/main/ | > metadata: | > name: example | > annotations: - | > backstage.io/techdocs-ref: . # -> same folder + | > backstage.io/techdocs-ref: dir:. # -> same folder | > spec: {} |- docs/ |- mkdocs.yml @@ -45,7 +44,7 @@ https://bitbucket.org/my-owner/my-project/src/master/ | > metadata: | > name: example | > annotations: - | > backstage.io/techdocs-ref: ./some-folder # -> subfolder + | > backstage.io/techdocs-ref: dir:./some-folder # -> subfolder | > spec: {} |- some-folder/ |- docs/ @@ -63,7 +62,7 @@ https://dev.azure.com/organization/project/_git/repository | > metadata: | > name: my-1st-module | > annotations: - | > backstage.io/techdocs-ref: . # -> same folder + | > backstage.io/techdocs-ref: dir:. # -> same folder | > spec: {} |- docs/ |- mkdocs.yml @@ -74,7 +73,7 @@ https://dev.azure.com/organization/project/_git/repository | > metadata: | > name: my-2nd-module | > annotations: - | > backstage.io/techdocs-ref: . # -> same folder + | > backstage.io/techdocs-ref: dir:. # -> same folder | > spec: {} |- docs/ |- mkdocs.yml diff --git a/packages/techdocs-common/api-report.md b/packages/techdocs-common/api-report.md index 9fc1795c13..6e845c9797 100644 --- a/packages/techdocs-common/api-report.md +++ b/packages/techdocs-common/api-report.md @@ -137,18 +137,6 @@ export const getDefaultBranch: ( config: Config, ) => Promise; -// Warning: (ae-missing-release-tag) "getDirLocation" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) -// -// @public -export const getDirLocation: ( - entity: Entity, -) => - | { - type: 'dir'; - target: string; - } - | undefined; - // Warning: (ae-missing-release-tag) "getDocFilesFromRepository" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -382,6 +370,7 @@ export type TechDocsMetadata = { // @public export const transformDirLocation: ( entity: Entity, + dirAnnotation: ParsedLocationAnnotation, scmIntegrations: ScmIntegrationRegistry, ) => { type: 'dir' | 'url'; diff --git a/packages/techdocs-common/src/helpers.test.ts b/packages/techdocs-common/src/helpers.test.ts index 6b677c6f5e..69f09d92ed 100644 --- a/packages/techdocs-common/src/helpers.test.ts +++ b/packages/techdocs-common/src/helpers.test.ts @@ -26,7 +26,6 @@ import os from 'os'; import path from 'path'; import { Readable } from 'stream'; import { - getDirLocation, getDocFilesFromRepository, getLocationForEntity, parseReferenceAnnotation, @@ -126,76 +125,11 @@ describe('parseReferenceAnnotation', () => { }); }); -describe('getDirLocation', () => { - it.each` - techdocsRef | responseTarget - ${undefined} | ${undefined} - ${16} | ${undefined} - ${'.'} | ${'.'} - ${'dir:.'} | ${'.'} - ${'./relative'} | ${'./relative'} - ${'dir:./relative'} | ${'./relative'} - ${'dir:https://github.com...'} | ${'https://github.com...'} - ${'url:https://github.com...'} | ${undefined} - `( - 'should handle "backstage.io/techdocs-ref: $techdocsRef" correctly', - ({ techdocsRef, responseTarget }) => { - const entity = { - apiVersion: '1', - kind: 'Component', - metadata: { - name: 'test', - annotations: { - 'backstage.io/techdocs-ref': techdocsRef, - }, - }, - }; - - const result = getDirLocation(entity); - - expect(result).toEqual( - responseTarget && { type: 'dir', target: responseTarget }, - ); - }, - ); - - it('Reject https urls and hint to the url: location type', async () => { - const entity = { - apiVersion: '1', - kind: 'Component', - metadata: { - name: 'test', - annotations: { - 'backstage.io/techdocs-ref': 'https://github.com/backstage/backstage', - }, - }, - }; - - expect(() => getDirLocation(entity)).toThrow( - /please prefix it with 'url:'/, - ); - }); -}); - describe('transformDirLocation', () => { - it('should reject missing annotation', () => { - const entity = { - apiVersion: '1', - kind: 'Component', - metadata: { - name: 'test', - }, - }; - - expect(() => transformDirLocation(entity, scmIntegrations)).toThrow( - /No techdocs location annotation provided in entity: component:default\/test/, - ); - }); - it.each` - techdocsRef | target - ${'.'} | ${'https://my-url/folder/'} - ${'./sub-folder'} | ${'https://my-url/folder/sub-folder'} + techdocsRef | target + ${'dir:.'} | ${'https://my-url/folder/'} + ${'dir:./sub-folder'} | ${'https://my-url/folder/sub-folder'} `( 'should transform "$techdocsRef" for url type locations', ({ techdocsRef, target }) => { @@ -215,16 +149,20 @@ describe('transformDirLocation', () => { }, }; - const result = transformDirLocation(entity, scmIntegrations); + const result = transformDirLocation( + entity, + parseReferenceAnnotation('backstage.io/techdocs-ref', entity), + scmIntegrations, + ); expect(result).toEqual({ type: 'url', target }); }, ); it.each` - techdocsRef | target - ${'.'} | ${path.join(rootDir, 'working-copy')} - ${'./sub-folder'} | ${path.join(rootDir, 'working-copy', 'sub-folder')} + techdocsRef | target + ${'dir:.'} | ${path.join(rootDir, 'working-copy')} + ${'dir:./sub-folder'} | ${path.join(rootDir, 'working-copy', 'sub-folder')} `( 'should transform "$techdocsRef" for file type locations', ({ techdocsRef, target }) => { @@ -244,7 +182,11 @@ describe('transformDirLocation', () => { }, }; - const result = transformDirLocation(entity, scmIntegrations); + const result = transformDirLocation( + entity, + parseReferenceAnnotation('backstage.io/techdocs-ref', entity), + scmIntegrations, + ); expect(result).toEqual({ type: 'dir', target }); }, @@ -262,12 +204,18 @@ describe('transformDirLocation', () => { metadata: { name: 'test', annotations: { - 'backstage.io/techdocs-ref': '..', + 'backstage.io/techdocs-ref': 'dir:..', }, }, }; - expect(() => transformDirLocation(entity, scmIntegrations)).toThrow( + expect(() => + transformDirLocation( + entity, + parseReferenceAnnotation('backstage.io/techdocs-ref', entity), + scmIntegrations, + ), + ).toThrow( /Relative path is not allowed to refer to a directory outside its parent/, ); }); @@ -284,43 +232,22 @@ describe('transformDirLocation', () => { metadata: { name: 'test', annotations: { - 'backstage.io/techdocs-ref': '.', + 'backstage.io/techdocs-ref': 'dir:.', }, }, }; - expect(() => transformDirLocation(entity, scmIntegrations)).toThrow( - /Unable to resolve location type other/, - ); + expect(() => + transformDirLocation( + entity, + parseReferenceAnnotation('backstage.io/techdocs-ref', entity), + scmIntegrations, + ), + ).toThrow(/Unable to resolve location type other/); }); }); describe('getLocationForEntity', () => { - it('should handle implicit dir locations', () => { - (getEntitySourceLocation as jest.Mock).mockReturnValue({ - type: 'url', - target: 'https://my-url/folder/', - }); - - const entity = { - apiVersion: '1', - kind: 'Component', - metadata: { - name: 'test', - annotations: { - 'backstage.io/techdocs-ref': '.', - }, - }, - }; - - const parsedLocationAnnotation = getLocationForEntity( - entity, - scmIntegrations, - ); - expect(parsedLocationAnnotation.type).toBe('url'); - expect(parsedLocationAnnotation.target).toBe('https://my-url/folder/'); - }); - it('should handle dir locations', () => { (getEntitySourceLocation as jest.Mock).mockReturnValue({ type: 'url', diff --git a/packages/techdocs-common/src/helpers.ts b/packages/techdocs-common/src/helpers.ts index bb640c1cce..bb6461a37c 100644 --- a/packages/techdocs-common/src/helpers.ts +++ b/packages/techdocs-common/src/helpers.ts @@ -23,7 +23,6 @@ import { Entity, getEntitySourceLocation, parseLocationReference, - stringifyEntityRef, } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; import { InputError } from '@backstage/errors'; @@ -60,44 +59,6 @@ export const parseReferenceAnnotation = ( }; }; -/** - * Check if the entity provides a `backstage.io/techdocs-ref` annotation of type `dir` - * and return the annotation. It accepts the two variants `` and `dir:`. - * - * @param entity - the entity to check - */ -export const getDirLocation = ( - entity: Entity, -): { type: 'dir'; target: string } | undefined => { - const annotation = entity.metadata.annotations?.['backstage.io/techdocs-ref']; - - if (!annotation) { - return undefined; - } - - if (typeof annotation !== 'string') { - return undefined; - } - - // if the string doesn't contain `:`, interpret it as the target of a dir type - if (!annotation.includes(':')) { - return { type: 'dir', target: annotation }; - } - - // note that `backstage.io/techdocs-ref: https://...` is invalid and will throw - const reference = parseLocationReference(annotation); - - if (reference.type === 'dir') { - return { - type: 'dir', - target: reference.target, - }; - } - - // ignore any other types - return undefined; -}; - /** * TechDocs references of type `dir` are relative the source location of the entity. * This function transforms relative references to absolute ones, based on the @@ -107,30 +68,22 @@ export const getDirLocation = ( * an absolute `dir` location. * * @param entity - the entity with annotations - * @param scmIntegrations - access to the scmIntegrationt to do url transformations + * @param dirAnnotation - the parsed techdocs-ref annotation of type 'dir' + * @param scmIntegrations - access to the scmIntegration to do url transformations * @throws if the entity doesn't specify a `dir` location or is ingested from an unsupported location. * @returns the transformed location with an absolute target. */ export const transformDirLocation = ( entity: Entity, + dirAnnotation: ParsedLocationAnnotation, scmIntegrations: ScmIntegrationRegistry, ): { type: 'dir' | 'url'; target: string } => { - const dirLocation = getDirLocation(entity); - - if (!dirLocation) { - throw new InputError( - `No techdocs location annotation provided in entity: ${stringifyEntityRef( - entity, - )}`, - ); - } - const location = getEntitySourceLocation(entity); switch (location.type) { case 'url': { const target = scmIntegrations.resolveUrl({ - url: dirLocation.target, + url: dirAnnotation.target, base: location.target, }); @@ -144,7 +97,7 @@ export const transformDirLocation = ( // only permit targets in the same folder as the target of the `file` location! const target = resolveSafeChildPath( path.dirname(location.target), - dirLocation.target, + dirAnnotation.target, ); return { @@ -162,24 +115,21 @@ export const getLocationForEntity = ( entity: Entity, scmIntegration: ScmIntegrationRegistry, ): ParsedLocationAnnotation => { - // try to resolve relative references first - if (getDirLocation(entity) !== undefined) { - return transformDirLocation(entity, scmIntegration); - } - - const { type, target } = parseReferenceAnnotation( + const annotation = parseReferenceAnnotation( 'backstage.io/techdocs-ref', entity, ); - switch (type) { + switch (annotation.type) { case 'github': case 'gitlab': case 'azure/api': case 'url': - return { type, target }; + return annotation; + case 'dir': + return transformDirLocation(entity, annotation, scmIntegration); default: - throw new Error(`Invalid reference annotation ${type}`); + throw new Error(`Invalid reference annotation ${annotation.type}`); } }; diff --git a/packages/techdocs-common/src/stages/prepare/dir.ts b/packages/techdocs-common/src/stages/prepare/dir.ts index b0ba2a36b3..ade0b00162 100644 --- a/packages/techdocs-common/src/stages/prepare/dir.ts +++ b/packages/techdocs-common/src/stages/prepare/dir.ts @@ -23,7 +23,7 @@ import { ScmIntegrations, } from '@backstage/integration'; import { Logger } from 'winston'; -import { transformDirLocation } from '../../helpers'; +import { parseReferenceAnnotation, transformDirLocation } from '../../helpers'; import { PreparerBase, PreparerResponse } from './types'; export class DirectoryPreparer implements PreparerBase { @@ -39,18 +39,29 @@ export class DirectoryPreparer implements PreparerBase { entity: Entity, options?: { logger?: Logger; etag?: string }, ): Promise { - const { type, target } = transformDirLocation(entity, this.scmIntegrations); + const annotation = parseReferenceAnnotation( + 'backstage.io/techdocs-ref', + entity, + ); + const { type, target } = transformDirLocation( + entity, + annotation, + this.scmIntegrations, + ); switch (type) { case 'url': { - options?.logger?.info(`Download documentation from ${target}`); + options?.logger?.debug(`Reading files from ${target}`); // the target is an absolute url since it has already been transformed const response = await this.reader.readTree(target, { etag: options?.etag, }); + const preparedDir = await response.dir(); + + options?.logger?.debug(`Tree downloaded and stored at ${preparedDir}`); return { - preparedDir: await response.dir(), + preparedDir, etag: response.etag, }; } diff --git a/packages/techdocs-common/src/stages/prepare/preparers.ts b/packages/techdocs-common/src/stages/prepare/preparers.ts index 48e99d48f5..12fcb01013 100644 --- a/packages/techdocs-common/src/stages/prepare/preparers.ts +++ b/packages/techdocs-common/src/stages/prepare/preparers.ts @@ -15,11 +15,10 @@ */ import { UrlReader } from '@backstage/backend-common'; -import { Entity, parseLocationReference } from '@backstage/catalog-model'; +import { Entity } from '@backstage/catalog-model'; import { Config } from '@backstage/config'; -import { InputError } from '@backstage/errors'; import { Logger } from 'winston'; -import { getDirLocation } from '../../helpers'; +import { parseReferenceAnnotation } from '../../helpers'; import { CommonGitPreparer } from './commonGit'; import { DirectoryPreparer } from './dir'; import { PreparerBase, PreparerBuilder, RemoteProtocol } from './types'; @@ -63,20 +62,10 @@ export class Preparers implements PreparerBuilder { } get(entity: Entity): PreparerBase { - const annotation = - entity.metadata.annotations?.['backstage.io/techdocs-ref']; - if (!annotation) { - throw new InputError( - `No location annotation provided in entity: ${entity.metadata.name}`, - ); - } - - // the dir processor handles both `` and `dir:` - if (getDirLocation(entity) !== undefined) { - return this.preparerMap.get('dir')!; - } - - const { type } = parseLocationReference(annotation); + const { type } = parseReferenceAnnotation( + 'backstage.io/techdocs-ref', + entity, + ); const preparer = this.preparerMap.get(type as RemoteProtocol); if (!preparer) {