Skip to content

Commit

Permalink
fix(rubygems): Use cascade of endpoints for unknown servers (#23523)
Browse files Browse the repository at this point in the history
  • Loading branch information
zharinov authored Jul 25, 2023
1 parent 89621be commit 992b336
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 120 deletions.
74 changes: 34 additions & 40 deletions lib/modules/datasource/rubygems/common.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,44 @@
import { assignKeys } from '../../../util/assign-keys';
import { type Http, HttpError, type SafeJsonError } from '../../../util/http';
import { type AsyncResult, Result } from '../../../util/result';
import type { Http, SafeJsonError } from '../../../util/http';
import type { AsyncResult } from '../../../util/result';
import { joinUrlParts as join } from '../../../util/url';
import type { Release, ReleaseResult } from '../types';
import type { ReleaseResult } from '../types';
import { GemMetadata, GemVersions } from './schema';

export function getV1Releases(
export function assignMetadata(
releases: ReleaseResult,
metadata: GemMetadata
): ReleaseResult {
return assignKeys(releases, metadata, [
'changelogUrl',
'sourceUrl',
'homepage',
]);
}

export function getV1Metadata(
http: Http,
registryUrl: string,
packageName: string
): AsyncResult<
ReleaseResult,
SafeJsonError | 'empty-releases' | 'unsupported-api'
> {
const fileName = `${packageName}.json`;
const versionsUrl = join(registryUrl, '/api/v1/versions', fileName);
const metadataUrl = join(registryUrl, '/api/v1/gems', fileName);

const addMetadata = (releases: Release[]): Promise<ReleaseResult> =>
http
.getJsonSafe(metadataUrl, GemMetadata)
.transform((metadata) =>
assignKeys({ releases } as ReleaseResult, metadata, [
'changelogUrl',
'sourceUrl',
'homepage',
])
)
.unwrap({ releases });
): AsyncResult<GemMetadata, SafeJsonError> {
const metadataUrl = join(registryUrl, '/api/v1/gems', `${packageName}.json`);
return http.getJsonSafe(metadataUrl, GemMetadata);
}

return http
.getJsonSafe(versionsUrl, GemVersions)
.catch((err) => {
if (err instanceof HttpError) {
const status = err.response?.statusCode;
if (status === 404 || status === 400) {
return Result.err('unsupported-api');
}
}
export function getV1Releases(
http: Http,
registryUrl: string,
packageName: string
): AsyncResult<ReleaseResult, SafeJsonError | 'unsupported-api'> {
const versionsUrl = join(
registryUrl,
'/api/v1/versions',
`${packageName}.json`
);

return Result.err(err);
})
.transform((releases) => {
return releases.length > 0
? Result.ok(releases)
: Result.err('empty-releases');
})
.transform(addMetadata);
return http.getJsonSafe(versionsUrl, GemVersions).transform((releaseResult) =>
getV1Metadata(http, registryUrl, packageName)
.transform((metadata) => assignMetadata(releaseResult, metadata))
.unwrap(releaseResult)
);
}
69 changes: 62 additions & 7 deletions lib/modules/datasource/rubygems/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ describe('modules/datasource/rubygems/index', () => {
.scope('https://example.com')
.get('/api/v1/versions/foobar.json')
.reply(200, [])
.get('/info/foobar')
.reply(200, '')
.get('/api/v1/dependencies?gems=foobar')
.reply(200, rubyMarshal([]));
expect(
Expand Down Expand Up @@ -116,20 +118,22 @@ describe('modules/datasource/rubygems/index', () => {
httpMock
.scope('https://registry-1.com/')
.get('/api/v1/versions/foobar.json')
.reply(400)
.reply(404)
.get('/info/foobar')
.reply(404)
.get('/api/v1/dependencies?gems=foobar')
.reply(404);

httpMock
.scope('https://registry-2.com/nested/path')
.get('/api/v1/gems/foobar.json')
.reply(200, {})
.get('/api/v1/versions/foobar.json')
.reply(200, [
{ number: '1.0.0', created_at: '2021-01-01' },
{ number: '2.0.0', created_at: '2022-01-01' },
{ number: '3.0.0', created_at: '2023-01-01' },
])
.get('/api/v1/gems/foobar.json')
.reply(200, {});
]);

const res = await getPkgReleases({
versioning: rubyVersioning.id,
Expand All @@ -155,7 +159,9 @@ describe('modules/datasource/rubygems/index', () => {
httpMock
.scope('https://example.com/')
.get('/api/v1/versions/foobar.json')
.reply(400, {})
.reply(404, {})
.get('/info/foobar')
.reply(404)
.get('/api/v1/dependencies?gems=foobar')
.reply(
200,
Expand Down Expand Up @@ -183,11 +189,60 @@ describe('modules/datasource/rubygems/index', () => {
});
});

it('errors when version request fails with anything other than 400 or 404', async () => {
it('supports /info endpoint', async () => {
httpMock
.scope('https://example.com/')
.get('/api/v1/versions/foobar.json')
.reply(404)
.get('/info/foobar')
.reply(
200,
codeBlock`
1.0.0 |checksum:aaa
2.0.0 |checksum:bbb
3.0.0 |checksum:ccc
`
);

const res = await getPkgReleases({
versioning: rubyVersioning.id,
datasource: RubyGemsDatasource.id,
packageName: 'foobar',
registryUrls: ['https://example.com'],
});
expect(res).toEqual({
registryUrl: 'https://example.com',
releases: [
{ version: '1.0.0' },
{ version: '2.0.0' },
{ version: '3.0.0' },
],
});
});

it('errors when version request fails with server error', async () => {
httpMock
.scope('https://example.com/')
.get('/api/v1/versions/foobar.json')
.reply(500);

await expect(
getPkgReleases({
versioning: rubyVersioning.id,
datasource: RubyGemsDatasource.id,
packageName: 'foobar',
registryUrls: ['https://example.com'],
})
).rejects.toThrow(ExternalHostError);
});

it('errors when dependencies request fails server error', async () => {
httpMock
.scope('https://example.com/')
.get('/info/foobar')
.reply(404)
.get('/api/v1/versions/foobar.json')
.reply(500, {})
.reply(404)
.get('/api/v1/dependencies?gems=foobar')
.reply(500);

Expand Down
43 changes: 38 additions & 5 deletions lib/modules/datasource/rubygems/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Marshal } from '@qnighy/marshal';
import type { ZodError } from 'zod';
import { logger } from '../../../logger';
import { HttpError } from '../../../util/http';
import { AsyncResult, Result } from '../../../util/result';
import { getQueryString, joinUrlParts, parseUrl } from '../../../util/url';
import * as rubyVersioning from '../../versioning/ruby';
Expand All @@ -9,9 +10,22 @@ import type { GetReleasesConfig, ReleaseResult } from '../types';
import { getV1Releases } from './common';
import { RubygemsHttp } from './http';
import { MetadataCache } from './metadata-cache';
import { MarshalledVersionInfo } from './schema';
import { GemInfo, MarshalledVersionInfo } from './schema';
import { VersionsEndpointCache } from './versions-endpoint-cache';

function unlessServerSide<T, E>(
err: E,
cb: () => AsyncResult<T, E>
): AsyncResult<T, E> {
if (err instanceof HttpError && err.response?.statusCode) {
const code = err.response.statusCode;
if (code >= 500 && code <= 599) {
return AsyncResult.err(err);
}
}
return cb();
}

export class RubyGemsDatasource extends Datasource {
static readonly id = 'rubygems';

Expand Down Expand Up @@ -56,9 +70,17 @@ export class RubyGemsDatasource extends Datasource {
) {
result = this.getReleasesViaDeprecatedAPI(registryUrl, packageName);
} else {
result = getV1Releases(this.http, registryUrl, packageName).catch(() =>
this.getReleasesViaDeprecatedAPI(registryUrl, packageName)
);
result = getV1Releases(this.http, registryUrl, packageName)
.catch((err) =>
unlessServerSide(err, () =>
this.getReleasesViaInfoEndpoint(registryUrl, packageName)
)
)
.catch((err) =>
unlessServerSide(err, () =>
this.getReleasesViaDeprecatedAPI(registryUrl, packageName)
)
);
}

const { val, err } = await result.unwrap();
Expand All @@ -74,6 +96,17 @@ export class RubyGemsDatasource extends Datasource {
return null;
}

private getReleasesViaInfoEndpoint(
registryUrl: string,
packageName: string
): AsyncResult<ReleaseResult, Error | ZodError> {
const url = joinUrlParts(registryUrl, '/info', packageName);
return Result.wrap(this.http.get(url)).transform(({ body }) => {
const res = GemInfo.safeParse(body);
return res.success ? Result.ok(res.data) : Result.err(res.error);
});
}

private getReleasesViaDeprecatedAPI(
registryUrl: string,
packageName: string
Expand All @@ -86,7 +119,7 @@ export class RubyGemsDatasource extends Datasource {
const data = Marshal.parse(body);
const releases = MarshalledVersionInfo.safeParse(data);
return releases.success
? Result.ok({ releases: releases.data })
? Result.ok(releases.data)
: Result.err(releases.error);
});
}
Expand Down
Loading

0 comments on commit 992b336

Please sign in to comment.