diff --git a/docs/usage/configuration-options.md b/docs/usage/configuration-options.md index 54ece3ce5218361279792aa94e99f818f0fc0f65..9101b42677705427ffb731439fdce91b3eb13519 100644 --- a/docs/usage/configuration-options.md +++ b/docs/usage/configuration-options.md @@ -663,7 +663,7 @@ As this is a template it can be dynamically set. E.g. add the `packageName` as p ### format Defines which format the API is returning. -Only `json` is supported, but more are planned for future. +Currently `json` or `plain` are supported, see the `custom` [datasource documentation](/modules/datasource/custom/) for more information. ### transformTemplates diff --git a/lib/config/options/index.ts b/lib/config/options/index.ts index 6c51005969b98278fca00811a20137b6cd61d955..77a57330c21963ac21ebac6682e5d749b7f5ac79 100644 --- a/lib/config/options/index.ts +++ b/lib/config/options/index.ts @@ -74,7 +74,7 @@ const options: RenovateOptions[] = [ type: 'string', parent: 'customDatasources', default: 'json', - allowedValues: ['json'], + allowedValues: ['json', 'plain'], cli: false, env: false, }, diff --git a/lib/config/types.ts b/lib/config/types.ts index 96bf67fa9e58d7415353900f38243a993ff9b058..58a36ab50155ca493bd11402463eea5493d2b313 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -277,7 +277,7 @@ export interface RenovateConfig export interface CustomDatasourceConfig { defaultRegistryUrlTemplate?: string; - format?: 'json'; + format?: 'json' | 'plain'; transformTemplates?: string[]; } diff --git a/lib/modules/datasource/custom/index.spec.ts b/lib/modules/datasource/custom/index.spec.ts index 901c6f16515dc47bfe67b4940d6619e3818c41b8..e6edfe1227c53a8aa78b76039064ed44651bde34 100644 --- a/lib/modules/datasource/custom/index.spec.ts +++ b/lib/modules/datasource/custom/index.spec.ts @@ -84,6 +84,120 @@ describe('modules/datasource/custom/index', () => { expect(result).toEqual(expected); }); + it('return releases for plain text API directly exposing in Renovate format', async () => { + const expected = { + releases: [ + { + version: '1.0.0', + }, + { + version: '2.0.0', + }, + { + version: '3.0.0', + }, + ], + }; + httpMock + .scope('https://example.com') + .get('/v1') + .reply(200, '1.0.0\n2.0.0\n3.0.0', { + 'Content-Type': 'text/plain', + }); + const result = await getPkgReleases({ + datasource: `${CustomDatasource.id}.foo`, + packageName: 'myPackage', + customDatasources: { + foo: { + defaultRegistryUrlTemplate: 'https://example.com/v1', + format: 'plain', + }, + }, + }); + expect(result).toEqual(expected); + }); + + it('return releases for plain text API and trim the content', async () => { + const expected = { + releases: [ + { + version: '1.0.0', + }, + { + version: '2.0.0', + }, + { + version: '3.0.0', + }, + ], + }; + httpMock + .scope('https://example.com') + .get('/v1') + .reply(200, '1.0.0 \n2.0.0 \n 3.0.0 ', { + 'Content-Type': 'text/plain', + }); + const result = await getPkgReleases({ + datasource: `${CustomDatasource.id}.foo`, + packageName: 'myPackage', + customDatasources: { + foo: { + defaultRegistryUrlTemplate: 'https://example.com/v1', + format: 'plain', + }, + }, + }); + expect(result).toEqual(expected); + }); + + it('return releases for plain text API when only returns a single version', async () => { + const expected = { + releases: [ + { + version: '1.0.0', + }, + ], + }; + httpMock.scope('https://example.com').get('/v1').reply(200, '1.0.0', { + 'Content-Type': 'text/plain', + }); + const result = await getPkgReleases({ + datasource: `${CustomDatasource.id}.foo`, + packageName: 'myPackage', + customDatasources: { + foo: { + defaultRegistryUrlTemplate: 'https://example.com/v1', + format: 'plain', + }, + }, + }); + expect(result).toEqual(expected); + }); + + it('return null for plain text API if the body is not what is expected', async () => { + const expected = { + releases: [ + { + version: '1.0.0', + }, + ], + }; + httpMock.scope('https://example.com').get('/v1').reply(200, expected, { + 'Content-Type': 'application/json', + }); + const result = await getPkgReleases({ + datasource: `${CustomDatasource.id}.foo`, + packageName: 'myPackage', + customDatasources: { + foo: { + defaultRegistryUrlTemplate: 'https://example.com/v1', + format: 'plain', + }, + }, + }); + expect(result).toBeNull(); + }); + it('return release when templating registryUrl', async () => { const expected = { releases: [ diff --git a/lib/modules/datasource/custom/index.ts b/lib/modules/datasource/custom/index.ts index 4a4906c670014c6f7789ac206788aa3f6f403a50..cb1739fd827e246953300c7f0582b403637f2d8f 100644 --- a/lib/modules/datasource/custom/index.ts +++ b/lib/modules/datasource/custom/index.ts @@ -1,6 +1,7 @@ import is from '@sindresorhus/is'; import jsonata from 'jsonata'; import { logger } from '../../../logger'; +import { newlineRegex } from '../../../util/regex'; import { Datasource } from '../datasource'; import type { GetReleasesConfig, ReleaseResult } from '../types'; import { ReleaseResultZodSchema } from './schema'; @@ -38,11 +39,15 @@ export class CustomDatasource extends Datasource { return null; } - const { defaultRegistryUrlTemplate, transformTemplates } = config; - // TODO add here other format options than JSON + const { defaultRegistryUrlTemplate, transformTemplates, format } = config; + // TODO add here other format options than JSON and "plain" let response: unknown; try { - response = (await this.http.getJson(defaultRegistryUrlTemplate)).body; + if (format === 'plain') { + response = await this.fetchPlainFormat(defaultRegistryUrlTemplate); + } else { + response = (await this.http.getJson(defaultRegistryUrlTemplate)).body; + } } catch (e) { this.handleHttpErrors(e); return null; @@ -64,4 +69,24 @@ export class CustomDatasource extends Datasource { return null; } } + + private async fetchPlainFormat(url: string): Promise<unknown> { + const response = await this.http.get(url, { + headers: { + Accept: 'text/plain', + }, + }); + const contentType = response.headers['content-type']; + if (!contentType?.startsWith('text/')) { + return null; + } + const versions = response.body.split(newlineRegex).map((version) => { + return { + version: version.trim(), + }; + }); + return { + releases: versions, + }; + } } diff --git a/lib/modules/datasource/custom/readme.md b/lib/modules/datasource/custom/readme.md index 20d35d0246b4431447de88fae6479d8d6e607d1f..1585e996babe9ec7326f39acbc9dd3490c90376c 100644 --- a/lib/modules/datasource/custom/readme.md +++ b/lib/modules/datasource/custom/readme.md @@ -11,7 +11,7 @@ Options: | option | default | description | | -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | defaultRegistryUrlTemplate | "" | url used if no `registryUrl` is provided when looking up new releases | -| format | "json" | format used by the API. Available values are: `json` | +| format | "json" | format used by the API. Available values are: `json`, `plain` | | transformTemplates | [] | [jsonata rules](https://docs.jsonata.org/simple) to transform the API output. Each rule will be evaluated after another and the result will be used as input to the next | Available template variables: @@ -78,6 +78,46 @@ All available options: } ``` +### Formats + +#### JSON + +If `json` is used processing works as described above. +The returned body will be directly interpreted as JSON and forwarded to the transformation rules. + +#### Plain + +If the format is set to `plain`, Renovate will call the HTTP endpoint with the `Accept` header value `text/plain`. +The body of the response will be treated as plain text and will be converted into JSON. + +Suppose the body of the HTTP response is as follows:: + +``` +1.0.0 +2.0.0 +3.0.0 +``` + +When Renovate receives this response with the `plain` format, it will convert it into the following: + +```json +{ + "releases": [ + { + "version": "1.0.0" + }, + { + "version": "2.0.0" + }, + { + "version": "3.0.0" + } + ] +} +``` + +After the conversion, any `jsonata` rules defined in the `transformTemplates` section will be applied as usual to further process the JSON data. + ## Examples ### K3s