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,
);