diff --git a/lib/modules/datasource/rubygems/common.ts b/lib/modules/datasource/rubygems/common.ts index 97a7e78ed9233d..a212f7f833398d 100644 --- a/lib/modules/datasource/rubygems/common.ts +++ b/lib/modules/datasource/rubygems/common.ts @@ -1,6 +1,6 @@ import { assignKeys } from '../../../util/assign-keys'; -import type { Http, SafeJsonError } from '../../../util/http'; -import type { AsyncResult } from '../../../util/result'; +import { type Http, HttpError, type SafeJsonError } from '../../../util/http'; +import { type AsyncResult, Result } from '../../../util/result'; import { joinUrlParts as join } from '../../../util/url'; import type { Release, ReleaseResult } from '../types'; import { GemMetadata, GemVersions } from './schema'; @@ -9,7 +9,10 @@ export function getV1Releases( http: Http, registryUrl: string, packageName: string -): AsyncResult { +): 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); @@ -26,5 +29,22 @@ export function getV1Releases( ) .unwrap({ releases }); - return http.getJsonSafe(versionsUrl, GemVersions).transform(addMetadata); + 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'); + } + } + + return Result.err(err); + }) + .transform((releases) => { + return releases.length > 0 + ? Result.ok(releases) + : Result.err('empty-releases'); + }) + .transform(addMetadata); } diff --git a/lib/modules/datasource/rubygems/index.spec.ts b/lib/modules/datasource/rubygems/index.spec.ts index 81fd7de90a94c6..d50a54ccfc4665 100644 --- a/lib/modules/datasource/rubygems/index.spec.ts +++ b/lib/modules/datasource/rubygems/index.spec.ts @@ -23,20 +23,19 @@ describe('modules/datasource/rubygems/index', () => { httpMock .scope('https://firstparty.com') .get('/basepath/versions') - .reply(404); - httpMock - .scope('https://firstparty.com') - .get('/basepath/api/v1/gems/rails.json') - .reply(200, { name: 'rails' }) + .reply(404) .get('/basepath/api/v1/versions/rails.json') - .reply(200, []); - httpMock.scope('https://thirdparty.com').get('/versions').reply(404); + .reply(200, []) + .get('/basepath/api/v1/dependencies?gems=rails') + .reply(200, emptyMarshalArray); httpMock .scope('https://thirdparty.com') - .get('/api/v1/gems/rails.json') - .reply(200, { name: 'rails' }) + .get('/versions') + .reply(404) .get('/api/v1/versions/rails.json') - .reply(200, []); + .reply(200, []) + .get('/api/v1/dependencies?gems=rails') + .reply(200, emptyMarshalArray); expect( await getPkgReleases({ versioning: rubyVersioning.id, @@ -151,16 +150,18 @@ describe('modules/datasource/rubygems/index', () => { .scope('https://thirdparty.com/') .get('/versions') .reply(404) - .get('/api/v1/gems/rails.json') - .reply(401); + .get('/api/v1/versions/rails.json') + .reply(400) + .get('/api/v1/dependencies?gems=rails') + .reply(200, emptyMarshalArray); httpMock .scope('https://firstparty.com/') .get('/basepath/versions') .reply(404) - .get('/basepath/api/v1/gems/rails.json') - .reply(200, railsInfo) .get('/basepath/api/v1/versions/rails.json') - .reply(200, railsVersions); + .reply(200, railsVersions) + .get('/basepath/api/v1/gems/rails.json') + .reply(200, railsInfo); const res = await getPkgReleases({ versioning: rubyVersioning.id, @@ -175,26 +176,22 @@ describe('modules/datasource/rubygems/index', () => { expect(res).toMatchSnapshot(); }); - it('falls back to info when version request fails', async () => { + it('falls back to dependencies when other API requests fail', async () => { httpMock .scope('https://thirdparty.com/') .get('/versions') .reply(404) - .get('/api/v1/gems/rails.json') - .reply(200, railsInfo) .get('/api/v1/versions/rails.json') - .reply(400, {}); + .reply(400, {}) + .get('/api/v1/dependencies?gems=rails') + .reply(200, railsDependencies); const res = await getPkgReleases({ versioning: rubyVersioning.id, datasource: RubyGemsDatasource.id, packageName: 'rails', - registryUrls: [ - 'https://thirdparty.com', - 'https://firstparty.com/basepath/', - ], + registryUrls: ['https://thirdparty.com'], }); - expect(res?.releases).toHaveLength(1); - expect(res?.releases[0].version).toBe(railsInfo.version); + expect(res?.releases).toHaveLength(339); }); it('errors when version request fails with anything other than 400 or 404', async () => { @@ -202,8 +199,6 @@ describe('modules/datasource/rubygems/index', () => { .scope('https://thirdparty.com/') .get('/versions') .reply(404) - .get('/api/v1/gems/rails.json') - .reply(200, railsInfo) .get('/api/v1/versions/rails.json') .reply(500, {}); await expect( @@ -224,7 +219,7 @@ describe('modules/datasource/rubygems/index', () => { .scope('https://thirdparty.com/') .get('/versions') .reply(404) - .get('/api/v1/gems/rails.json') + .get('/api/v1/versions/rails.json') .reply(404, railsInfo) .get('/api/v1/dependencies?gems=rails') .reply(200, railsDependencies); @@ -233,10 +228,7 @@ describe('modules/datasource/rubygems/index', () => { versioning: rubyVersioning.id, datasource: RubyGemsDatasource.id, packageName: 'rails', - registryUrls: [ - 'https://thirdparty.com', - 'https://firstparty.com/basepath/', - ], + registryUrls: ['https://thirdparty.com'], }); expect(res?.releases).toHaveLength(339); }); diff --git a/lib/modules/datasource/rubygems/index.ts b/lib/modules/datasource/rubygems/index.ts index 5f767400b38d42..7ad388e630219f 100644 --- a/lib/modules/datasource/rubygems/index.ts +++ b/lib/modules/datasource/rubygems/index.ts @@ -1,15 +1,14 @@ import { Marshal } from '@qnighy/marshal'; -import { logger } from '../../../logger'; -import { cache } from '../../../util/cache/package/decorator'; import { HttpError } from '../../../util/http'; import { Result } from '../../../util/result'; import { getQueryString, joinUrlParts, parseUrl } from '../../../util/url'; import * as rubyVersioning from '../../versioning/ruby'; import { Datasource } from '../datasource'; -import type { GetReleasesConfig, Release, ReleaseResult } from '../types'; +import type { GetReleasesConfig, ReleaseResult } from '../types'; +import { getV1Releases } from './common'; import { RubygemsHttp } from './http'; import { MetadataCache } from './metadata-cache'; -import { GemMetadata, GemVersions, MarshalledVersionInfo } from './schema'; +import { MarshalledVersionInfo } from './schema'; import { VersionsEndpointCache } from './versions-endpoint-cache'; export class RubyGemsDatasource extends Datasource { @@ -70,19 +69,18 @@ export class RubyGemsDatasource extends Datasource { return await this.getReleasesViaFallbackAPI(registryUrl, packageName); } - const gemMetadata = await this.fetchGemMetadata( + const { val: apiV1Result, err: apiV1Error } = await getV1Releases( + this.http, registryUrl, packageName - ); - if (!gemMetadata) { - return await this.getReleasesViaFallbackAPI(registryUrl, packageName); + ).unwrap(); + if (apiV1Result) { + return apiV1Result; + } else if (apiV1Error instanceof HttpError) { + throw apiV1Error; } - return await this.getReleasesViaAPI( - registryUrl, - packageName, - gemMetadata - ); + return await this.getReleasesViaFallbackAPI(registryUrl, packageName); } return null; @@ -91,102 +89,6 @@ export class RubyGemsDatasource extends Datasource { } } - @cache({ - namespace: `datasource-${RubyGemsDatasource.id}`, - key: ({ registryUrl, packageName }: GetReleasesConfig) => - // TODO: types (#7154) - /* eslint-disable @typescript-eslint/restrict-template-expressions */ - `metadata:${registryUrl}/${packageName}`, - }) - async fetchGemMetadata( - registryUrl: string, - packageName: string - ): Promise { - try { - const { body } = await this.http.getJson( - joinUrlParts(registryUrl, '/api/v1/gems', `${packageName}.json`), - GemMetadata - ); - return body; - } catch (err) { - // fallback to deps api on 404 - if (err instanceof HttpError && err.response?.statusCode === 404) { - return null; - } - throw err; - } - } - - @cache({ - namespace: `datasource-${RubyGemsDatasource.id}`, - key: ({ registryUrl, packageName }: GetReleasesConfig) => - // TODO: types (#7154) - /* eslint-disable @typescript-eslint/restrict-template-expressions */ - `versions:${registryUrl}/${packageName}`, - }) - async fetchGemVersions( - registryUrl: string, - packageName: string - ): Promise { - try { - const { body } = await this.http.getJson( - joinUrlParts(registryUrl, '/api/v1/versions', `${packageName}.json`), - GemVersions - ); - return body; - } catch (err) { - if (err.statusCode === 400 || err.statusCode === 404) { - logger.debug( - { registry: registryUrl }, - 'versions endpoint returns error - falling back to info endpoint' - ); - return null; - } else { - throw err; - } - } - } - - async getReleasesViaAPI( - registryUrl: string, - packageName: string, - gemMetadata: GemMetadata - ): Promise { - const gemVersions = await this.fetchGemVersions(registryUrl, packageName); - - let releases: Release[] | null = null; - if (gemVersions?.length) { - releases = gemVersions; - } else if (gemMetadata.latestVersion) { - releases = [{ version: gemMetadata.latestVersion }]; - } else { - return null; - } - - const result: ReleaseResult = { releases }; - - if (gemMetadata.changelogUrl) { - result.changelogUrl = gemMetadata.changelogUrl; - } - - if (gemMetadata.homepage) { - result.homepage = gemMetadata.homepage; - } - - if (gemMetadata.sourceUrl) { - result.sourceUrl = gemMetadata.sourceUrl; - } - - return result; - } - - @cache({ - namespace: `datasource-${RubyGemsDatasource.id}`, - key: ({ registryUrl, packageName }: GetReleasesConfig) => - // TODO: types (#7154) - /* eslint-disable @typescript-eslint/restrict-template-expressions */ - `dependencies:${registryUrl}/${packageName}`, - }) async getReleasesViaFallbackAPI( registryUrl: string, packageName: string