Skip to content

Commit

Permalink
refactor(datasource): migrate to class based datasource
Browse files Browse the repository at this point in the history
    A small experiment into what OOP/class based datasources might look like. Picked Cdnjs as it's the smallest & simplest.

    With this approach we can share common logic, like error handling, rate limiting, etc. between different datasources, instead of having to reimplement it each time we write a new datasource. Currently there's nothing shared, as it's only 1 datasource, but the interesting stuff will come with the 2nd datasource
  • Loading branch information
JamieMagee committed May 5, 2021
1 parent e33df09 commit ce19bb2
Show file tree
Hide file tree
Showing 16 changed files with 265 additions and 96 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ module.exports = {
'prefer-template': 0,
'no-underscore-dangle': 0,
'no-negated-condition': 'error',

'sort-imports': [
'error',
{
Expand Down
8 changes: 4 additions & 4 deletions lib/datasource/api.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as bitbucketTags from './bitbucket-tags';
import * as cdnjs from './cdnjs';
import * as clojure from './clojure';
import { CdnJsDatasource } from './cdnjs';
import { ClojureDatasource } from './clojure';
import * as crate from './crate';
import * as dart from './dart';
import * as docker from './docker';
Expand Down Expand Up @@ -36,8 +36,8 @@ const api = new Map<string, DatasourceApi>();
export default api;

api.set('bitbucket-tags', bitbucketTags);
api.set('cdnjs', cdnjs);
api.set('clojure', clojure);
api.set('cdnjs', new CdnJsDatasource());
api.set('clojure', new ClojureDatasource());
api.set('crate', crate);
api.set('dart', dart);
api.set('docker', docker);
Expand Down
2 changes: 1 addition & 1 deletion lib/datasource/cdnjs/__snapshots__/index.spec.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ Array [
]
`;

exports[`datasource/cdnjs/index getReleases returns null for unknown error 1`] = `
exports[`datasource/cdnjs/index getReleases throws for unknown error 1`] = `
Array [
Object {
"headers": Object {
Expand Down
45 changes: 33 additions & 12 deletions lib/datasource/cdnjs/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { getPkgReleases } from '..';
import * as httpMock from '../../../test/http-mock';
import { getName, loadFixture } from '../../../test/util';
import { EXTERNAL_HOST_ERROR } from '../../constants/error-messages';
import { id as datasource } from '.';
import { CdnJsDatasource } from '.';

const res1 = loadFixture('d3-force.json');
const res2 = loadFixture('bulma.json');
Expand All @@ -26,21 +26,30 @@ describe(getName(), () => {
it('throws for empty result', async () => {
httpMock.scope(baseUrl).get(pathFor('foo/bar')).reply(200, null);
await expect(
getPkgReleases({ datasource, depName: 'foo/bar' })
getPkgReleases({
datasource: CdnJsDatasource.id,
depName: 'foo/bar',
})
).rejects.toThrow(EXTERNAL_HOST_ERROR);
expect(httpMock.getTrace()).toMatchSnapshot();
});
it('throws for error', async () => {
httpMock.scope(baseUrl).get(pathFor('foo/bar')).replyWithError('error');
await expect(
getPkgReleases({ datasource, depName: 'foo/bar' })
getPkgReleases({
datasource: CdnJsDatasource.id,
depName: 'foo/bar',
})
).rejects.toThrow(EXTERNAL_HOST_ERROR);
expect(httpMock.getTrace()).toMatchSnapshot();
});
it('returns null for 404', async () => {
httpMock.scope(baseUrl).get(pathFor('foo/bar')).reply(404);
expect(
await getPkgReleases({ datasource, depName: 'foo/bar' })
await getPkgReleases({
datasource: CdnJsDatasource.id,
depName: 'foo/bar',
})
).toBeNull();
expect(httpMock.getTrace()).toMatchSnapshot();
});
Expand All @@ -51,7 +60,7 @@ describe(getName(), () => {
.reply(200, {});
expect(
await getPkgReleases({
datasource,
datasource: CdnJsDatasource.id,
depName: 'doesnotexist/doesnotexist',
})
).toBeNull();
Expand All @@ -60,28 +69,40 @@ describe(getName(), () => {
it('throws for 401', async () => {
httpMock.scope(baseUrl).get(pathFor('foo/bar')).reply(401);
await expect(
getPkgReleases({ datasource, depName: 'foo/bar' })
getPkgReleases({
datasource: CdnJsDatasource.id,
depName: 'foo/bar',
})
).rejects.toThrow(EXTERNAL_HOST_ERROR);
expect(httpMock.getTrace()).toMatchSnapshot();
});
it('throws for 429', async () => {
httpMock.scope(baseUrl).get(pathFor('foo/bar')).reply(429);
await expect(
getPkgReleases({ datasource, depName: 'foo/bar' })
getPkgReleases({
datasource: CdnJsDatasource.id,
depName: 'foo/bar',
})
).rejects.toThrow(EXTERNAL_HOST_ERROR);
expect(httpMock.getTrace()).toMatchSnapshot();
});
it('throws for 5xx', async () => {
httpMock.scope(baseUrl).get(pathFor('foo/bar')).reply(502);
await expect(
getPkgReleases({ datasource, depName: 'foo/bar' })
getPkgReleases({
datasource: CdnJsDatasource.id,
depName: 'foo/bar',
})
).rejects.toThrow(EXTERNAL_HOST_ERROR);
expect(httpMock.getTrace()).toMatchSnapshot();
});
it('returns null for unknown error', async () => {
it('throws for unknown error', async () => {
httpMock.scope(baseUrl).get(pathFor('foo/bar')).replyWithError('error');
await expect(
getPkgReleases({ datasource, depName: 'foo/bar' })
getPkgReleases({
datasource: CdnJsDatasource.id,
depName: 'foo/bar',
})
).rejects.toThrow(EXTERNAL_HOST_ERROR);
expect(httpMock.getTrace()).toMatchSnapshot();
});
Expand All @@ -91,7 +112,7 @@ describe(getName(), () => {
.get(pathFor('d3-force/d3-force.js'))
.reply(200, res1);
const res = await getPkgReleases({
datasource,
datasource: CdnJsDatasource.id,
depName: 'd3-force/d3-force.js',
});
expect(res).toMatchSnapshot();
Expand All @@ -103,7 +124,7 @@ describe(getName(), () => {
.get(pathFor('bulma/only/0.7.5/style.css'))
.reply(200, res2);
const res = await getPkgReleases({
datasource,
datasource: CdnJsDatasource.id,
depName: 'bulma/only/0.7.5/style.css',
});
expect(res).toMatchSnapshot();
Expand Down
93 changes: 44 additions & 49 deletions lib/datasource/cdnjs/index.ts
Original file line number Diff line number Diff line change
@@ -1,61 +1,56 @@
import { ExternalHostError } from '../../types/errors/external-host-error';
import { Http } from '../../util/http';
import { Datasource } from '../datasource';
import type { GetReleasesConfig, ReleaseResult } from '../types';
import type { CdnjsResponse } from './types';

export const id = 'cdnjs';
export const customRegistrySupport = false;
export const defaultRegistryUrls = ['https://api.cdnjs.com/'];
export const caching = true;
export class CdnJsDatasource extends Datasource {
static readonly id = 'cdnjs';

const http = new Http(id);
constructor() {
super(CdnJsDatasource.id);
}

interface CdnjsAsset {
version: string;
files: string[];
sri?: Record<string, string>;
}
customRegistrySupport = false;

interface CdnjsResponse {
homepage?: string;
repository?: {
type: 'git' | unknown;
url?: string;
};
assets?: CdnjsAsset[];
}
defaultRegistryUrls = ['https://api.cdnjs.com/'];

export async function getReleases({
lookupName,
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
// Each library contains multiple assets, so we cache at the library level instead of per-asset
const library = lookupName.split('/')[0];
const url = `${registryUrl}libraries/${library}?fields=homepage,repository,assets`;
try {
const { assets, homepage, repository } = (
await http.getJson<CdnjsResponse>(url)
).body;
if (!assets) {
return null;
}
const assetName = lookupName.replace(`${library}/`, '');
const releases = assets
.filter(({ files }) => files.includes(assetName))
.map(({ version, sri }) => ({ version, newDigest: sri[assetName] }));
caching = true;

const result: ReleaseResult = { releases };
// this.handleErrors will always throw
// eslint-disable-next-line consistent-return
async getReleases({
lookupName,
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
// Each library contains multiple assets, so we cache at the library level instead of per-asset
const library = lookupName.split('/')[0];
const url = `${registryUrl}libraries/${library}?fields=homepage,repository,assets`;
try {
const { assets, homepage, repository } = (
await this.http.getJson<CdnjsResponse>(url)
).body;
if (!assets) {
return null;
}
const assetName = lookupName.replace(`${library}/`, '');
const releases = assets
.filter(({ files }) => files.includes(assetName))
.map(({ version, sri }) => ({ version, newDigest: sri[assetName] }));

if (homepage) {
result.homepage = homepage;
}
if (repository?.url) {
result.sourceUrl = repository.url;
}
return result;
} catch (err) {
if (err.statusCode !== 404) {
throw new ExternalHostError(err);
const result: ReleaseResult = { releases };

if (homepage) {
result.homepage = homepage;
}
if (repository?.url) {
result.sourceUrl = repository.url;
}
return result;
} catch (err) {
if (err.statusCode !== 404) {
throw new ExternalHostError(err);
}
this.handleGenericErrors(err);
}
throw err;
}
}
14 changes: 14 additions & 0 deletions lib/datasource/cdnjs/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
interface CdnjsAsset {
version: string;
files: string[];
sri?: Record<string, string>;
}

export interface CdnjsResponse {
homepage?: string;
repository?: {
type: 'git' | unknown;
url?: string;
};
assets?: CdnjsAsset[];
}
52 changes: 52 additions & 0 deletions lib/datasource/clojure/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Release, getPkgReleases } from '..';
import { getName } from '../../../test/util';
import { id as mavenVersioning } from '../../versioning/maven';
import { ClojureDatasource } from '.';

const config = {
versioning: mavenVersioning,
datasource: ClojureDatasource.id,
};

describe(getName(), () => {
describe('getReleases', () => {
function generateReleases(versions: string[]): Release[] {
return versions.map((v) => ({ version: v }));
}

it('should return empty if library is not found', async () => {
const releases = await getPkgReleases({
...config,
depName: 'unknown:unknown',
registryUrls: [
's3://somewhere.s3.aws.amazon.com',
'file://lib/datasource/maven/__fixtures__/repo1.maven.org/maven2/',
],
});
expect(releases).toBeNull();
});

it('should simply return all versions of a specific library', async () => {
const releases = await getPkgReleases({
...config,
depName: 'org.hamcrest:hamcrest-core',
registryUrls: [
'file://lib/datasource/maven/__fixtures__/repo1.maven.org/maven2/',
'file://lib/datasource/maven/__fixtures__/custom_maven_repo/maven2/',
's3://somewhere.s3.aws.amazon.com',
],
});
expect(releases.releases).toEqual(
generateReleases([
'1.1',
'1.2',
'1.2.1',
'1.3.RC2',
'1.3',
'2.1-rc2',
'2.1-rc3',
])
);
});
});
});
28 changes: 23 additions & 5 deletions lib/datasource/clojure/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,26 @@
import { Datasource } from '../datasource';
import { getReleases } from '../maven';
import { MAVEN_REPO } from '../maven/common';
import type { GetReleasesConfig, ReleaseResult } from '../types';

export const id = 'clojure';
export const customRegistrySupport = true;
export const defaultRegistryUrls = ['https://clojars.org/repo', MAVEN_REPO];
export const registryStrategy = 'merge';
export class ClojureDatasource extends Datasource {
static readonly id = 'clojure';

export { getReleases } from '../maven';
constructor() {
super(ClojureDatasource.id);
}

readonly registryStrategy = 'merge';

readonly customRegistrySupport = true;

readonly defaultRegistryUrls = ['https://clojars.org/repo', MAVEN_REPO];

// eslint-disable-next-line class-methods-use-this
getReleases({
lookupName,
registryUrl,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
return getReleases({ lookupName, registryUrl });
}
}
Loading

0 comments on commit ce19bb2

Please sign in to comment.