diff --git a/.changeset/techdocs-search-log-fatigue.md b/.changeset/techdocs-search-log-fatigue.md new file mode 100644 index 0000000000..dc81563d8c --- /dev/null +++ b/.changeset/techdocs-search-log-fatigue.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-techdocs-backend': patch +--- + +Errors encountered while attempting to load TechDocs search indices at +collation-time are now logged at `DEBUG` instead of `WARN` level. diff --git a/.changeset/techdocs-the-beta-local.md b/.changeset/techdocs-the-beta-local.md new file mode 100644 index 0000000000..9f94627a60 --- /dev/null +++ b/.changeset/techdocs-the-beta-local.md @@ -0,0 +1,6 @@ +--- +'@backstage/techdocs-common': patch +--- + +"Local" (out-of-the-box) publisher explicitly follows lower-case entity triplet +logic. diff --git a/packages/techdocs-common/src/stages/publish/local.test.ts b/packages/techdocs-common/src/stages/publish/local.test.ts index 0ced296a88..38ead236bd 100644 --- a/packages/techdocs-common/src/stages/publish/local.test.ts +++ b/packages/techdocs-common/src/stages/publish/local.test.ts @@ -25,10 +25,10 @@ import mockFs from 'mock-fs'; import * as os from 'os'; import { LocalPublish } from './local'; -const createMockEntity = (annotations = {}) => { +const createMockEntity = (annotations = {}, lowerCase = false) => { return { apiVersion: 'version', - kind: 'TestKind', + kind: lowerCase ? 'testkind' : 'TestKind', metadata: { name: 'test-component-name', annotations: { @@ -65,11 +65,42 @@ describe('local publisher', () => { const publisher = new LocalPublish(mockConfig, logger, testDiscovery); const mockEntity = createMockEntity(); + const lowerMockEntity = createMockEntity(undefined, true); await publisher.publish({ entity: mockEntity, directory: tmpDir }); expect(await publisher.hasDocsBeenGenerated(mockEntity)).toBe(true); + // Lower/upper should be treated the same. + expect(await publisher.hasDocsBeenGenerated(lowerMockEntity)).toBe(true); + + mockFs.restore(); + }); + + it('should respect legacy casing', async () => { + mockFs({ + [tmpDir]: { + 'index.html': '', + }, + }); + + const mockConfig = new ConfigReader({ + techdocs: { + legacyUseCaseSensitiveTripletPaths: true, + }, + }); + + const publisher = new LocalPublish(mockConfig, logger, testDiscovery); + const mockEntity = createMockEntity(); + const lowerMockEntity = createMockEntity(undefined, true); + + await publisher.publish({ entity: mockEntity, directory: tmpDir }); + + expect(await publisher.hasDocsBeenGenerated(mockEntity)).toBe(true); + + // Lower/upper should be treated differently. + expect(await publisher.hasDocsBeenGenerated(lowerMockEntity)).toBe(false); + mockFs.restore(); }); @@ -86,6 +117,13 @@ describe('local publisher', () => { [resolvedDir]: { 'unsafe.html': '', 'unsafe.svg': '', + default: { + testkind: { + testname: { + 'index.html': 'found it', + }, + }, + }, }, }); }); @@ -107,5 +145,38 @@ describe('local publisher', () => { 'content-type': 'text/plain; charset=utf-8', }); }); + + it('should redirect case-sensitive triplet path to lower-case', async () => { + const response = await request(app) + .get('/default/TestKind/TestName/index.html') + .expect('Location', '/default/testkind/testname/index.html'); + expect(response.status).toBe(301); + }); + + it('should resolve lower-case triplet path content eventually', async () => { + const response = await request(app) + .get('/default/TestKind/TestName/index.html') + .redirects(1); + expect(response.text).toEqual('found it'); + }); + + it('should not redirect when legacy case setting is used', async () => { + const legacyConfig = new ConfigReader({ + techdocs: { + legacyUseCaseSensitiveTripletPaths: true, + }, + }); + const legacyPublisher = new LocalPublish( + legacyConfig, + logger, + testDiscovery, + ); + app = express().use(legacyPublisher.docsRouter()); + + const response = await request(app).get( + '/default/TestKind/TestName/index.html', + ); + expect(response.status).toBe(404); + }); }); }); diff --git a/packages/techdocs-common/src/stages/publish/local.ts b/packages/techdocs-common/src/stages/publish/local.ts index 24af34932f..c547b65406 100644 --- a/packages/techdocs-common/src/stages/publish/local.ts +++ b/packages/techdocs-common/src/stages/publish/local.ts @@ -58,6 +58,8 @@ try { * called "static" at the root of techdocs-backend plugin. */ export class LocalPublish implements PublisherBase { + private legacyPathCasing: boolean; + // TODO: Use a static fromConfig method to create a LocalPublish instance, similar to aws/gcs publishers. // Move the logic of setting staticDocsDir based on config over to fromConfig, // and set the value as a class parameter. @@ -70,6 +72,10 @@ export class LocalPublish implements PublisherBase { this.config = config; this.logger = logger; this.discovery = discovery; + this.legacyPathCasing = + config.getOptionalBoolean( + 'techdocs.legacyUseCaseSensitiveTripletPaths', + ) || false; } async getReadiness(): Promise { @@ -81,8 +87,7 @@ export class LocalPublish implements PublisherBase { publish({ entity, directory }: PublishRequest): Promise { const entityNamespace = entity.metadata.namespace ?? 'default'; - const publishDir = path.join( - staticDocsDir, + const publishDir = this.staticEntityPathJoin( entityNamespace, entity.kind, entity.metadata.name, @@ -119,8 +124,7 @@ export class LocalPublish implements PublisherBase { async fetchTechDocsMetadata( entityName: EntityName, ): Promise { - const metadataPath = path.join( - staticDocsDir, + const metadataPath = this.staticEntityPathJoin( entityName.namespace, entityName.kind, entityName.name, @@ -138,23 +142,61 @@ export class LocalPublish implements PublisherBase { } docsRouter(): express.Handler { - return express.static(staticDocsDir, { - // Handle content-type header the same as all other publishers. - setHeaders: (res, filePath) => { - const fileExtension = path.extname(filePath); - const headers = getHeadersForFileExtension(fileExtension); - for (const [header, value] of Object.entries(headers)) { - res.setHeader(header, value); - } - }, + const router = express.Router(); + + // Redirect middleware ensuring that requests to case-sensitive entity + // triplet paths are always sent to lower-case versions. + router.use((req, res, next) => { + // If legacy path casing is on, let the request immediately continue. + if (this.legacyPathCasing) { + return next(); + } + + // Generate a lower-case entity triplet path. + const [_, namespace, kind, name, ...rest] = req.path.split('/'); + + // Ignore non-triplet objects. + if (!namespace || !kind || !name) { + return next(); + } + + const newPath = [ + _, + namespace.toLowerCase(), + kind.toLowerCase(), + name.toLowerCase(), + ...rest, + ].join('/'); + + // If there was no change, then let express.static() handle the request. + if (newPath === req.path) { + return next(); + } + + // Otherwise, redirect to the new path. + return res.redirect(req.baseUrl + newPath, 301); }); + + router.use( + express.static(staticDocsDir, { + // Handle content-type header the same as all other publishers. + setHeaders: (res, filePath) => { + const fileExtension = path.extname(filePath); + const headers = getHeadersForFileExtension(fileExtension); + for (const [header, value] of Object.entries(headers)) { + res.setHeader(header, value); + } + }, + }), + ); + + return router; } async hasDocsBeenGenerated(entity: Entity): Promise { const namespace = entity.metadata.namespace ?? 'default'; - const indexHtmlPath = path.join( - staticDocsDir, + const indexHtmlPath = this.staticEntityPathJoin( namespace, entity.kind, entity.metadata.name, @@ -210,4 +252,22 @@ export class LocalPublish implements PublisherBase { ), ); } + + /** + * Utility wrapper around path.join(), used to control legacy case logic. + */ + protected staticEntityPathJoin(...allParts: string[]): string { + if (this.legacyPathCasing) { + const [namespace, kind, name, ...parts] = allParts; + return path.join(staticDocsDir, namespace, kind, name, ...parts); + } + const [namespace, kind, name, ...parts] = allParts; + return path.join( + staticDocsDir, + namespace.toLowerCase(), + kind.toLowerCase(), + name.toLowerCase(), + ...parts, + ); + } } diff --git a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts index f525d7af7e..850be2823c 100644 --- a/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts +++ b/plugins/techdocs-backend/src/search/DefaultTechDocsCollator.ts @@ -110,7 +110,7 @@ export class DefaultTechDocsCollator implements DocumentCollator { ?.target?.name || '', })); } catch (e) { - this.logger.warn( + this.logger.debug( `Failed to retrieve tech docs search index for entity ${entityInfo.namespace}/${entityInfo.kind}/${entityInfo.name}`, e, );