Merge pull request #7003 from backstage/techdocs/beta-local

This commit is contained in:
Eric Peterson
2021-08-31 20:43:54 +02:00
committed by GitHub
5 changed files with 161 additions and 18 deletions
@@ -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.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/techdocs-common': patch
---
"Local" (out-of-the-box) publisher explicitly follows lower-case entity triplet
logic.
@@ -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': '<html></html>',
'unsafe.svg': '<svg></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);
});
});
});
@@ -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<ReadinessResponse> {
@@ -81,8 +87,7 @@ export class LocalPublish implements PublisherBase {
publish({ entity, directory }: PublishRequest): Promise<PublishResponse> {
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<TechDocsMetadata> {
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<boolean> {
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,
);
}
}
@@ -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,
);