Merge pull request #3307 from mfrinnstrom/azureurlreader-readtree
AzureUrlReader readTree() support
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
"@backstage/test-utils": "^0.1.3",
|
||||
"@types/cors": "^2.8.6",
|
||||
"@types/express": "^4.17.6",
|
||||
"archiver": "^5.0.2",
|
||||
"compression": "^1.7.4",
|
||||
"concat-stream": "^2.0.0",
|
||||
"cors": "^2.8.5",
|
||||
@@ -55,6 +56,7 @@
|
||||
"selfsigned": "^1.10.7",
|
||||
"stoppable": "^1.1.0",
|
||||
"tar": "^6.0.5",
|
||||
"unzipper": "^0.10.11",
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -67,6 +69,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.3.0",
|
||||
"@types/archiver": "^3.1.1",
|
||||
"@types/compression": "^1.7.0",
|
||||
"@types/concat-stream": "^1.6.0",
|
||||
"@types/fs-extra": "^9.0.3",
|
||||
@@ -78,6 +81,7 @@
|
||||
"@types/stoppable": "^1.1.0",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"@types/tar": "^4.0.3",
|
||||
"@types/unzipper": "^0.10.3",
|
||||
"@types/webpack-env": "^1.15.2",
|
||||
"@types/yaml": "^1.9.7",
|
||||
"get-port": "^5.1.1",
|
||||
|
||||
@@ -14,11 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { ConfigReader } from '@backstage/config';
|
||||
import { getVoidLogger } from '../logging';
|
||||
import { AzureUrlReader } from './AzureUrlReader';
|
||||
import { AzureUrlReader, getDownloadUrl } from './AzureUrlReader';
|
||||
import { msw } from '@backstage/test-utils';
|
||||
import { ReadTreeResponseFactory } from './tree';
|
||||
|
||||
@@ -32,104 +34,165 @@ describe('AzureUrlReader', () => {
|
||||
const worker = setupServer();
|
||||
msw.setupDefaultHandlers(worker);
|
||||
|
||||
beforeEach(() => {
|
||||
worker.use(
|
||||
rest.get('*', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
url: req.url.toString(),
|
||||
headers: req.headers.getAllHeaders(),
|
||||
}),
|
||||
describe('read', () => {
|
||||
beforeEach(() => {
|
||||
worker.use(
|
||||
rest.get('*', (req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
url: req.url.toString(),
|
||||
headers: req.headers.getAllHeaders(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const createConfig = (token?: string) =>
|
||||
new ConfigReader(
|
||||
{
|
||||
integrations: { azure: [{ host: 'dev.azure.com', token }] },
|
||||
},
|
||||
'test-config',
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
url:
|
||||
'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster',
|
||||
config: createConfig(),
|
||||
response: expect.objectContaining({
|
||||
url:
|
||||
'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master',
|
||||
}),
|
||||
},
|
||||
{
|
||||
url:
|
||||
'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml',
|
||||
config: createConfig(),
|
||||
response: expect.objectContaining({
|
||||
url:
|
||||
'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml',
|
||||
}),
|
||||
},
|
||||
{
|
||||
url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml',
|
||||
config: createConfig('0123456789'),
|
||||
response: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
authorization: 'Basic OjAxMjM0NTY3ODk=',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml',
|
||||
config: createConfig(undefined),
|
||||
response: expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
authorization: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
])('should handle happy path %#', async ({ url, config, response }) => {
|
||||
const [{ reader }] = AzureUrlReader.factory({
|
||||
config,
|
||||
logger,
|
||||
treeResponseFactory,
|
||||
);
|
||||
});
|
||||
|
||||
const data = await reader.read(url);
|
||||
const res = await JSON.parse(data.toString('utf-8'));
|
||||
expect(res).toEqual(response);
|
||||
});
|
||||
const createConfig = (token?: string) =>
|
||||
new ConfigReader(
|
||||
{
|
||||
integrations: { azure: [{ host: 'dev.azure.com', token }] },
|
||||
},
|
||||
'test-config',
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
url: 'https://api.com/a/b/blob/master/path/to/c.yaml',
|
||||
config: createConfig(),
|
||||
error:
|
||||
'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path',
|
||||
},
|
||||
{
|
||||
url: 'com/a/b/blob/master/path/to/c.yaml',
|
||||
config: createConfig(),
|
||||
error:
|
||||
'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml',
|
||||
},
|
||||
{
|
||||
url: '',
|
||||
config: createConfig(''),
|
||||
error:
|
||||
"Invalid type in config for key 'integrations.azure[0].token' in 'test-config', got empty-string, wanted string",
|
||||
},
|
||||
])('should handle error path %#', async ({ url, config, error }) => {
|
||||
await expect(async () => {
|
||||
it.each([
|
||||
{
|
||||
url:
|
||||
'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml&version=GBmaster',
|
||||
config: createConfig(),
|
||||
response: expect.objectContaining({
|
||||
url:
|
||||
'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml&version=master',
|
||||
}),
|
||||
},
|
||||
{
|
||||
url:
|
||||
'https://dev.azure.com/org-name/project-name/_git/repo-name?path=my-template.yaml',
|
||||
config: createConfig(),
|
||||
response: expect.objectContaining({
|
||||
url:
|
||||
'https://dev.azure.com/org-name/project-name/_apis/git/repositories/repo-name/items?path=my-template.yaml',
|
||||
}),
|
||||
},
|
||||
{
|
||||
url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml',
|
||||
config: createConfig('0123456789'),
|
||||
response: expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
authorization: 'Basic OjAxMjM0NTY3ODk=',
|
||||
}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
url: 'https://dev.azure.com/a/b/_git/repo-name?path=my-template.yaml',
|
||||
config: createConfig(undefined),
|
||||
response: expect.objectContaining({
|
||||
headers: expect.not.objectContaining({
|
||||
authorization: expect.anything(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
])('should handle happy path %#', async ({ url, config, response }) => {
|
||||
const [{ reader }] = AzureUrlReader.factory({
|
||||
config,
|
||||
logger,
|
||||
treeResponseFactory,
|
||||
});
|
||||
await reader.read(url);
|
||||
}).rejects.toThrow(error);
|
||||
|
||||
const data = await reader.read(url);
|
||||
const res = await JSON.parse(data.toString('utf-8'));
|
||||
expect(res).toEqual(response);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
url: 'https://api.com/a/b/blob/master/path/to/c.yaml',
|
||||
config: createConfig(),
|
||||
error:
|
||||
'Incorrect url: https://api.com/a/b/blob/master/path/to/c.yaml, Error: Wrong Azure Devops URL or Invalid file path',
|
||||
},
|
||||
{
|
||||
url: 'com/a/b/blob/master/path/to/c.yaml',
|
||||
config: createConfig(),
|
||||
error:
|
||||
'Incorrect url: com/a/b/blob/master/path/to/c.yaml, TypeError: Invalid URL: com/a/b/blob/master/path/to/c.yaml',
|
||||
},
|
||||
{
|
||||
url: '',
|
||||
config: createConfig(''),
|
||||
error:
|
||||
"Invalid type in config for key 'integrations.azure[0].token' in 'test-config', got empty-string, wanted string",
|
||||
},
|
||||
])('should handle error path %#', async ({ url, config, error }) => {
|
||||
await expect(async () => {
|
||||
const [{ reader }] = AzureUrlReader.factory({
|
||||
config,
|
||||
logger,
|
||||
treeResponseFactory,
|
||||
});
|
||||
await reader.read(url);
|
||||
}).rejects.toThrow(error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readTree', () => {
|
||||
const repoBuffer = fs.readFileSync(
|
||||
path.resolve('src', 'reading', '__fixtures__', 'repo.zip'),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
worker.use(
|
||||
rest.get(
|
||||
'https://dev.azure.com/organization/project/_apis/git/repositories/repository/items',
|
||||
(_, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.set('Content-Type', 'application/zip'),
|
||||
ctx.body(repoBuffer),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the wanted files from an archive', async () => {
|
||||
const processor = new AzureUrlReader(
|
||||
{
|
||||
host: 'dev.azure.com',
|
||||
},
|
||||
{ treeResponseFactory },
|
||||
);
|
||||
|
||||
const response = await processor.readTree(
|
||||
'https://dev.azure.com/organization/project/_git/repository',
|
||||
);
|
||||
|
||||
const files = await response.files();
|
||||
|
||||
expect(files.length).toBe(2);
|
||||
const mkDocsFile = await files[1].content();
|
||||
const indexMarkdownFile = await files[0].content();
|
||||
|
||||
expect(mkDocsFile.toString()).toBe('site_name: Test\n');
|
||||
expect(indexMarkdownFile.toString()).toBe('# Test\n');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDownloadUrl', () => {
|
||||
it('do not add scopePath if no path is specified', async () => {
|
||||
const result = getDownloadUrl(
|
||||
'https://dev.azure.com/organization/project/_git/repository',
|
||||
);
|
||||
|
||||
expect(result.searchParams.get('scopePath')).toBeNull();
|
||||
});
|
||||
|
||||
it('add scopePath if a path is specified', async () => {
|
||||
const result = getDownloadUrl(
|
||||
'https://dev.azure.com/organization/project/_git/repository?path=%2Fdocs',
|
||||
);
|
||||
expect(result.searchParams.get('scopePath')).toEqual('docs');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,22 +19,55 @@ import {
|
||||
readAzureIntegrationConfigs,
|
||||
} from '@backstage/integration';
|
||||
import fetch from 'cross-fetch';
|
||||
import { Readable } from 'stream';
|
||||
import parseGitUri from 'git-url-parse';
|
||||
import { NotFoundError } from '../errors';
|
||||
import { ReaderFactory, ReadTreeResponse, UrlReader } from './types';
|
||||
import {
|
||||
ReaderFactory,
|
||||
ReadTreeOptions,
|
||||
ReadTreeResponse,
|
||||
UrlReader,
|
||||
} from './types';
|
||||
import { ReadTreeResponseFactory } from './tree';
|
||||
|
||||
export function getDownloadUrl(url: string): URL {
|
||||
const {
|
||||
name: repoName,
|
||||
owner: project,
|
||||
organization,
|
||||
protocol,
|
||||
resource,
|
||||
filepath,
|
||||
} = parseGitUri(url);
|
||||
|
||||
// scopePath will limit the downloaded content
|
||||
// /docs will only download the docs folder and everything below it
|
||||
// /docs/index.md will only download index.md but put it in the root of the archive
|
||||
const scopePath = filepath
|
||||
? `&scopePath=${encodeURIComponent(filepath)}`
|
||||
: '';
|
||||
|
||||
return new URL(
|
||||
`${protocol}://${resource}/${organization}/${project}/_apis/git/repositories/${repoName}/items?recursionLevel=full&download=true&api-version=6.0${scopePath}`,
|
||||
);
|
||||
}
|
||||
|
||||
export class AzureUrlReader implements UrlReader {
|
||||
static factory: ReaderFactory = ({ config }) => {
|
||||
static factory: ReaderFactory = ({ config, treeResponseFactory }) => {
|
||||
const configs = readAzureIntegrationConfigs(
|
||||
config.getOptionalConfigArray('integrations.azure') ?? [],
|
||||
);
|
||||
return configs.map(options => {
|
||||
const reader = new AzureUrlReader(options);
|
||||
const reader = new AzureUrlReader(options, { treeResponseFactory });
|
||||
const predicate = (url: URL) => url.host === options.host;
|
||||
return { reader, predicate };
|
||||
});
|
||||
};
|
||||
|
||||
constructor(private readonly options: AzureIntegrationConfig) {
|
||||
constructor(
|
||||
private readonly options: AzureIntegrationConfig,
|
||||
private readonly deps: { treeResponseFactory: ReadTreeResponseFactory },
|
||||
) {
|
||||
if (options.host !== 'dev.azure.com') {
|
||||
throw Error(
|
||||
`Azure integration currently only supports 'dev.azure.com', tried to use host '${options.host}'`,
|
||||
@@ -64,8 +97,26 @@ export class AzureUrlReader implements UrlReader {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
readTree(): Promise<ReadTreeResponse> {
|
||||
throw new Error('AzureUrlReader does not implement readTree');
|
||||
async readTree(
|
||||
url: string,
|
||||
options?: ReadTreeOptions,
|
||||
): Promise<ReadTreeResponse> {
|
||||
const response = await fetch(
|
||||
getDownloadUrl(url).toString(),
|
||||
this.getRequestOptions({ Accept: 'application/zip' }),
|
||||
);
|
||||
if (!response.ok) {
|
||||
const message = `Failed to read tree from ${url}, ${response.status} ${response.statusText}`;
|
||||
if (response.status === 404) {
|
||||
throw new NotFoundError(message);
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return this.deps.treeResponseFactory.fromZipArchive({
|
||||
stream: (response.body as unknown) as Readable,
|
||||
filter: options?.filter,
|
||||
});
|
||||
}
|
||||
|
||||
// Converts
|
||||
@@ -127,8 +178,10 @@ export class AzureUrlReader implements UrlReader {
|
||||
}
|
||||
}
|
||||
|
||||
private getRequestOptions(): RequestInit {
|
||||
const headers: HeadersInit = {};
|
||||
private getRequestOptions(additionalHeaders?: {
|
||||
[key: string]: string;
|
||||
}): RequestInit {
|
||||
const headers: HeadersInit = additionalHeaders ?? {};
|
||||
|
||||
if (this.options.token) {
|
||||
headers.Authorization = `Basic ${Buffer.from(
|
||||
|
||||
@@ -207,7 +207,7 @@ export class GithubUrlReader implements UrlReader {
|
||||
|
||||
const path = `${repoName}-${ref}/${filepath}`;
|
||||
|
||||
return this.deps.treeResponseFactory.fromArchive({
|
||||
return this.deps.treeResponseFactory.fromTarArchive({
|
||||
// TODO(Rugvip): Underlying implementation of fetch will be node-fetch, we probably want
|
||||
// to stick to using that in exclusively backend code.
|
||||
stream: (response.body as unknown) as Readable,
|
||||
|
||||
Binary file not shown.
@@ -18,7 +18,8 @@ import os from 'os';
|
||||
import { Readable } from 'stream';
|
||||
import { Config } from '@backstage/config';
|
||||
import { ReadTreeResponse } from '../types';
|
||||
import { ArchiveResponse } from './ArchiveResponse';
|
||||
import { TarArchiveResponse } from './TarArchiveResponse';
|
||||
import { ZipArchiveResponse } from './ZipArchiveResponse';
|
||||
|
||||
type FromArchiveOptions = {
|
||||
// A binary stream of a tar archive.
|
||||
@@ -39,8 +40,17 @@ export class ReadTreeResponseFactory {
|
||||
|
||||
constructor(private readonly workDir: string) {}
|
||||
|
||||
async fromArchive(options: FromArchiveOptions): Promise<ReadTreeResponse> {
|
||||
return new ArchiveResponse(
|
||||
async fromTarArchive(options: FromArchiveOptions): Promise<ReadTreeResponse> {
|
||||
return new TarArchiveResponse(
|
||||
options.stream,
|
||||
options.path ?? '',
|
||||
this.workDir,
|
||||
options.filter,
|
||||
);
|
||||
}
|
||||
|
||||
async fromZipArchive(options: FromArchiveOptions): Promise<ReadTreeResponse> {
|
||||
return new ZipArchiveResponse(
|
||||
options.stream,
|
||||
options.path ?? '',
|
||||
this.workDir,
|
||||
|
||||
+9
-9
@@ -17,13 +17,13 @@
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { ArchiveResponse } from './ArchiveResponse';
|
||||
import { TarArchiveResponse } from './TarArchiveResponse';
|
||||
|
||||
const archiveData = fs.readFileSync(
|
||||
resolvePath(__filename, '../../__fixtures__/repo.tar.gz'),
|
||||
);
|
||||
|
||||
describe('ArchiveResponse', () => {
|
||||
describe('TarArchiveResponse', () => {
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
'/test-archive.tar.gz': archiveData,
|
||||
@@ -38,7 +38,7 @@ describe('ArchiveResponse', () => {
|
||||
it('should read files', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
|
||||
const res = new ArchiveResponse(stream, 'mock-repo/', '/tmp');
|
||||
const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp');
|
||||
const files = await res.files();
|
||||
|
||||
expect(files).toEqual([
|
||||
@@ -61,7 +61,7 @@ describe('ArchiveResponse', () => {
|
||||
it('should read files with filter', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
|
||||
const res = new ArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
|
||||
const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
|
||||
path.endsWith('.yml'),
|
||||
);
|
||||
const files = await res.files();
|
||||
@@ -79,14 +79,14 @@ describe('ArchiveResponse', () => {
|
||||
it('should read as archive and files', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
|
||||
const res = new ArchiveResponse(stream, 'mock-repo/', '/tmp');
|
||||
const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp');
|
||||
const buffer = await res.archive();
|
||||
|
||||
await expect(res.archive()).rejects.toThrow(
|
||||
'Response has already been read',
|
||||
);
|
||||
|
||||
const res2 = new ArchiveResponse(buffer, '', '/tmp');
|
||||
const res2 = new TarArchiveResponse(buffer, '', '/tmp');
|
||||
const files = await res2.files();
|
||||
|
||||
expect(files).toEqual([
|
||||
@@ -109,7 +109,7 @@ describe('ArchiveResponse', () => {
|
||||
it('should extract entire archive into directory', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
|
||||
const res = new ArchiveResponse(stream, '', '/tmp');
|
||||
const res = new TarArchiveResponse(stream, '', '/tmp');
|
||||
const dir = await res.dir();
|
||||
|
||||
await expect(
|
||||
@@ -123,7 +123,7 @@ describe('ArchiveResponse', () => {
|
||||
it('should extract archive into directory with a subpath', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
|
||||
const res = new ArchiveResponse(stream, 'mock-repo/docs/', '/tmp');
|
||||
const res = new TarArchiveResponse(stream, 'mock-repo/docs/', '/tmp');
|
||||
const dir = await res.dir();
|
||||
|
||||
expect(dir).toMatch(/^\/tmp\/.*$/);
|
||||
@@ -135,7 +135,7 @@ describe('ArchiveResponse', () => {
|
||||
it('should extract archive into directory with a subpath and filter', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.tar.gz');
|
||||
|
||||
const res = new ArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
|
||||
const res = new TarArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
|
||||
path.endsWith('.yml'),
|
||||
);
|
||||
const dir = await res.dir({ targetDir: '/tmp' });
|
||||
+2
-2
@@ -34,7 +34,7 @@ const pipeline = promisify(pipelineCb);
|
||||
/**
|
||||
* Wraps a tar archive stream into a tree response reader.
|
||||
*/
|
||||
export class ArchiveResponse implements ReadTreeResponse {
|
||||
export class TarArchiveResponse implements ReadTreeResponse {
|
||||
private read = false;
|
||||
|
||||
constructor(
|
||||
@@ -49,7 +49,7 @@ export class ArchiveResponse implements ReadTreeResponse {
|
||||
}
|
||||
if (subPath.startsWith('/')) {
|
||||
throw new TypeError(
|
||||
`ArchiveResponse subPath must not start with a /, got '${subPath}'`,
|
||||
`TarArchiveResponse subPath must not start with a /, got '${subPath}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import mockFs from 'mock-fs';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { ZipArchiveResponse } from './ZipArchiveResponse';
|
||||
|
||||
const archiveData = fs.readFileSync(
|
||||
resolvePath(__filename, '../../__fixtures__/repo.zip'),
|
||||
);
|
||||
|
||||
describe('ZipArchiveResponse', () => {
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
'/test-archive.zip': archiveData,
|
||||
'/tmp': mockFs.directory(),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should read files', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.zip');
|
||||
|
||||
const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp');
|
||||
const files = await res.files();
|
||||
|
||||
expect(files).toEqual([
|
||||
{
|
||||
path: 'docs/index.md',
|
||||
content: expect.any(Function),
|
||||
},
|
||||
{
|
||||
path: 'mkdocs.yml',
|
||||
content: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
const contents = await Promise.all(files.map(f => f.content()));
|
||||
expect(contents.map(c => c.toString('utf8').trim())).toEqual([
|
||||
'# Test',
|
||||
'site_name: Test',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should read files with filter', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.zip');
|
||||
|
||||
const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
|
||||
path.endsWith('.yml'),
|
||||
);
|
||||
const files = await res.files();
|
||||
|
||||
expect(files).toEqual([
|
||||
{
|
||||
path: 'mkdocs.yml',
|
||||
content: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
const content = await files[0].content();
|
||||
expect(content.toString('utf8').trim()).toEqual('site_name: Test');
|
||||
});
|
||||
|
||||
it('should read as archive and files', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.zip');
|
||||
|
||||
const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp');
|
||||
const buffer = await res.archive();
|
||||
|
||||
await expect(res.archive()).rejects.toThrow(
|
||||
'Response has already been read',
|
||||
);
|
||||
|
||||
const res2 = new ZipArchiveResponse(buffer, '', '/tmp');
|
||||
const files = await res2.files();
|
||||
|
||||
expect(files).toEqual([
|
||||
{
|
||||
path: 'docs/index.md',
|
||||
content: expect.any(Function),
|
||||
},
|
||||
{
|
||||
path: 'mkdocs.yml',
|
||||
content: expect.any(Function),
|
||||
},
|
||||
]);
|
||||
const contents = await Promise.all(files.map(f => f.content()));
|
||||
expect(contents.map(c => c.toString('utf8').trim())).toEqual([
|
||||
'# Test',
|
||||
'site_name: Test',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should extract entire archive into directory', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.zip');
|
||||
|
||||
const res = new ZipArchiveResponse(stream, '', '/tmp');
|
||||
const dir = await res.dir();
|
||||
|
||||
await expect(
|
||||
fs.readFile(resolvePath(dir, 'mock-repo/mkdocs.yml'), 'utf8'),
|
||||
).resolves.toBe('site_name: Test\n');
|
||||
await expect(
|
||||
fs.readFile(resolvePath(dir, 'mock-repo/docs/index.md'), 'utf8'),
|
||||
).resolves.toBe('# Test\n');
|
||||
});
|
||||
|
||||
it('should extract archive into directory with a subpath', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.zip');
|
||||
|
||||
const res = new ZipArchiveResponse(stream, 'mock-repo/docs/', '/tmp');
|
||||
const dir = await res.dir();
|
||||
|
||||
expect(dir).toMatch(/^\/tmp\/.*$/);
|
||||
await expect(
|
||||
fs.readFile(resolvePath(dir, 'index.md'), 'utf8'),
|
||||
).resolves.toBe('# Test\n');
|
||||
});
|
||||
|
||||
it('should extract archive into directory with a subpath and filter', async () => {
|
||||
const stream = fs.createReadStream('/test-archive.zip');
|
||||
|
||||
const res = new ZipArchiveResponse(stream, 'mock-repo/', '/tmp', path =>
|
||||
path.endsWith('.yml'),
|
||||
);
|
||||
const dir = await res.dir({ targetDir: '/tmp' });
|
||||
|
||||
expect(dir).toBe('/tmp');
|
||||
await expect(fs.pathExists(resolvePath(dir, 'mkdocs.yml'))).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
await expect(
|
||||
fs.pathExists(resolvePath(dir, 'docs/index.md')),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import unzipper, { Entry } from 'unzipper';
|
||||
import archiver from 'archiver';
|
||||
import { Readable } from 'stream';
|
||||
import {
|
||||
ReadTreeResponse,
|
||||
ReadTreeResponseFile,
|
||||
ReadTreeResponseDirOptions,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Wraps a zip archive stream into a tree response reader.
|
||||
*/
|
||||
export class ZipArchiveResponse implements ReadTreeResponse {
|
||||
private read = false;
|
||||
|
||||
constructor(
|
||||
private readonly stream: Readable,
|
||||
private readonly subPath: string,
|
||||
private readonly workDir: string,
|
||||
private readonly filter?: (path: string) => boolean,
|
||||
) {
|
||||
if (subPath) {
|
||||
if (!subPath.endsWith('/')) {
|
||||
this.subPath += '/';
|
||||
}
|
||||
if (subPath.startsWith('/')) {
|
||||
throw new TypeError(
|
||||
`ZipArchiveResponse subPath must not start with a /, got '${subPath}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the input stream is only read once
|
||||
private onlyOnce() {
|
||||
if (this.read) {
|
||||
throw new Error('Response has already been read');
|
||||
}
|
||||
this.read = true;
|
||||
}
|
||||
|
||||
private getPath(entry: Entry): string {
|
||||
return entry.path.slice(this.subPath.length);
|
||||
}
|
||||
|
||||
private shouldBeIncluded(entry: Entry): boolean {
|
||||
if (this.subPath) {
|
||||
if (!entry.path.startsWith(this.subPath)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (this.filter) {
|
||||
return this.filter(this.getPath(entry));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async files(): Promise<ReadTreeResponseFile[]> {
|
||||
this.onlyOnce();
|
||||
|
||||
const files = Array<ReadTreeResponseFile>();
|
||||
|
||||
await this.stream
|
||||
.pipe(unzipper.Parse())
|
||||
.on('entry', (entry: Entry) => {
|
||||
if (entry.type === 'Directory') {
|
||||
entry.resume();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldBeIncluded(entry)) {
|
||||
files.push({
|
||||
path: this.getPath(entry),
|
||||
content: () => entry.buffer(),
|
||||
});
|
||||
} else {
|
||||
entry.autodrain();
|
||||
}
|
||||
})
|
||||
.promise();
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async archive(): Promise<Readable> {
|
||||
this.onlyOnce();
|
||||
|
||||
if (!this.subPath) {
|
||||
return this.stream;
|
||||
}
|
||||
|
||||
const archive = archiver('zip');
|
||||
await this.stream
|
||||
.pipe(unzipper.Parse())
|
||||
.on('entry', (entry: Entry) => {
|
||||
if (entry.type === 'File' && this.shouldBeIncluded(entry)) {
|
||||
archive.append(entry, { name: this.getPath(entry) });
|
||||
} else {
|
||||
entry.autodrain();
|
||||
}
|
||||
})
|
||||
.promise();
|
||||
archive.finalize();
|
||||
|
||||
return archive;
|
||||
}
|
||||
|
||||
async dir(options?: ReadTreeResponseDirOptions): Promise<string> {
|
||||
this.onlyOnce();
|
||||
|
||||
const dir =
|
||||
options?.targetDir ??
|
||||
(await fs.mkdtemp(path.join(this.workDir, 'backstage-')));
|
||||
|
||||
await this.stream
|
||||
.pipe(unzipper.Parse())
|
||||
.on('entry', async (entry: Entry) => {
|
||||
// Ignore directory entries since we handle that with the file entries
|
||||
// as a zip can have files with directories without directory entries
|
||||
if (entry.type === 'File' && this.shouldBeIncluded(entry)) {
|
||||
const entryPath = this.getPath(entry);
|
||||
const dirname = path.dirname(entryPath);
|
||||
if (dirname) {
|
||||
await fs.mkdirp(path.join(dir, dirname));
|
||||
}
|
||||
entry.pipe(fs.createWriteStream(path.join(dir, entryPath)));
|
||||
} else {
|
||||
entry.autodrain();
|
||||
}
|
||||
})
|
||||
.promise();
|
||||
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user