From 621b77b2c799792467817a2e0e6db977a3f51b7a Mon Sep 17 00:00:00 2001
From: Jamie Magee <jamie.magee@gmail.com>
Date: Thu, 20 May 2021 22:40:09 -0700
Subject: [PATCH] refactor(datasource): migrate to class based datasource
 (#6747)

* refactor(datasource): migrate to class based datasource

    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

* Apply suggestions from code review

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>

* remove renaming ClojureDatasource to datasource in tests

Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 lib/datasource/api.ts                         |   8 +-
 .../cdnjs/__snapshots__/index.spec.ts.snap    |   2 +-
 lib/datasource/cdnjs/index.spec.ts            |  45 +-
 lib/datasource/cdnjs/index.ts                 |  85 ++-
 .../clojure/__snapshots__/index.spec.ts.snap  | 679 ++++++++++++++++++
 lib/datasource/clojure/index.spec.ts          | 265 +++++++
 lib/datasource/clojure/index.ts               |  28 +-
 lib/datasource/datasource.spec.ts             |  44 ++
 lib/datasource/datasource.ts                  |  51 ++
 lib/datasource/index.spec.ts                  |  31 +-
 lib/datasource/index.ts                       |  14 +-
 lib/manager/cdnurl/extract.ts                 |   4 +-
 lib/manager/deps-edn/extract.ts               |   4 +-
 lib/manager/html/extract.ts                   |   4 +-
 lib/manager/leiningen/extract.spec.ts         |   8 +-
 lib/manager/leiningen/extract.ts              |   4 +-
 16 files changed, 1191 insertions(+), 85 deletions(-)
 create mode 100644 lib/datasource/clojure/__snapshots__/index.spec.ts.snap
 create mode 100644 lib/datasource/clojure/index.spec.ts
 create mode 100644 lib/datasource/datasource.spec.ts
 create mode 100644 lib/datasource/datasource.ts

diff --git a/lib/datasource/api.ts b/lib/datasource/api.ts
index c3b5100cc9..069d8ccd52 100644
--- a/lib/datasource/api.ts
+++ b/lib/datasource/api.ts
@@ -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';
@@ -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);
diff --git a/lib/datasource/cdnjs/__snapshots__/index.spec.ts.snap b/lib/datasource/cdnjs/__snapshots__/index.spec.ts.snap
index f6b82cfb89..eafc2cec56 100644
--- a/lib/datasource/cdnjs/__snapshots__/index.spec.ts.snap
+++ b/lib/datasource/cdnjs/__snapshots__/index.spec.ts.snap
@@ -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 {
diff --git a/lib/datasource/cdnjs/index.spec.ts b/lib/datasource/cdnjs/index.spec.ts
index 7819d61c4f..d81e627fbc 100644
--- a/lib/datasource/cdnjs/index.spec.ts
+++ b/lib/datasource/cdnjs/index.spec.ts
@@ -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');
@@ -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();
     });
@@ -51,7 +60,7 @@ describe(getName(), () => {
         .reply(200, {});
       expect(
         await getPkgReleases({
-          datasource,
+          datasource: CdnJsDatasource.id,
           depName: 'doesnotexist/doesnotexist',
         })
       ).toBeNull();
@@ -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();
     });
@@ -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();
@@ -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();
diff --git a/lib/datasource/cdnjs/index.ts b/lib/datasource/cdnjs/index.ts
index 7ac03579c4..0944d32d39 100644
--- a/lib/datasource/cdnjs/index.ts
+++ b/lib/datasource/cdnjs/index.ts
@@ -1,47 +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;
-
-const http = new Http(id);
-
-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] }));
+export class CdnJsDatasource extends Datasource {
+  static readonly id = 'cdnjs';
 
-    const result: ReleaseResult = { releases };
+  constructor() {
+    super(CdnJsDatasource.id);
+  }
 
-    if (homepage) {
-      result.homepage = homepage;
-    }
-    if (repository?.url) {
-      result.sourceUrl = repository.url;
-    }
-    return result;
-  } catch (err) {
-    if (err.statusCode !== 404) {
-      throw new ExternalHostError(err);
+  customRegistrySupport = false;
+
+  defaultRegistryUrls = ['https://api.cdnjs.com/'];
+
+  caching = true;
+
+  // 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] }));
+
+      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;
   }
 }
diff --git a/lib/datasource/clojure/__snapshots__/index.spec.ts.snap b/lib/datasource/clojure/__snapshots__/index.spec.ts.snap
new file mode 100644
index 0000000000..20e9cbbd5f
--- /dev/null
+++ b/lib/datasource/clojure/__snapshots__/index.spec.ts.snap
@@ -0,0 +1,679 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`datasource/clojure/index collects releases from all registry urls 1`] = `
+Array [
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://clojars.org/repo/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.0/package-1.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.1/package-1.0.1.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.2/package-1.0.2.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "custom.registry.renovatebot.com",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://custom.registry.renovatebot.com/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "custom.registry.renovatebot.com",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://custom.registry.renovatebot.com/org/example/package/3.0.0/package-3.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "custom.registry.renovatebot.com",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://custom.registry.renovatebot.com/org/example/package/3.0.0/package-3.0.0.pom",
+  },
+]
+`;
+
+exports[`datasource/clojure/index falls back to next registry url 1`] = `
+Object {
+  "display": "org.example:package",
+  "group": "org.example",
+  "homepage": "https://package.example.org/about",
+  "name": "package",
+  "registryUrl": "https://clojars.org/repo",
+  "releases": Array [
+    Object {
+      "releaseTimestamp": "2020-01-01T01:00:00.000Z",
+      "version": "1.0.0",
+    },
+    Object {
+      "releaseTimestamp": "2020-01-01T02:00:00.000Z",
+      "version": "2.0.0",
+    },
+  ],
+}
+`;
+
+exports[`datasource/clojure/index falls back to next registry url 2`] = `
+Array [
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "failed_repo",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://failed_repo/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "unauthorized_repo",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://unauthorized_repo/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "empty_repo",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://empty_repo/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "unknown_error",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://unknown_error/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://clojars.org/repo/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.0/package-1.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.1/package-1.0.1.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.2/package-1.0.2.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+]
+`;
+
+exports[`datasource/clojure/index handles optional slash at the end of registry url 1`] = `
+Array [
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://clojars.org/repo/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.0/package-1.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.1/package-1.0.1.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.2/package-1.0.2.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://clojars.org/repo/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.0/package-1.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.1/package-1.0.1.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.2/package-1.0.2.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+]
+`;
+
+exports[`datasource/clojure/index ignores unsupported protocols 1`] = `
+Array [
+  Object {
+    "releaseTimestamp": "2020-01-01T01:00:00.000Z",
+    "version": "1.0.0",
+  },
+  Object {
+    "releaseTimestamp": "2020-01-01T02:00:00.000Z",
+    "version": "2.0.0",
+  },
+]
+`;
+
+exports[`datasource/clojure/index ignores unsupported protocols 2`] = `
+Array [
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "http://clojars.org/repo/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "http://clojars.org/repo/org/example/package/1.0.0/package-1.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "http://clojars.org/repo/org/example/package/1.0.1/package-1.0.1.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "http://clojars.org/repo/org/example/package/1.0.2/package-1.0.2.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "http://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "http://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+]
+`;
+
+exports[`datasource/clojure/index returns releases from custom repository 1`] = `
+Object {
+  "display": "org.example:package",
+  "group": "org.example",
+  "homepage": "https://package.example.org/about",
+  "name": "package",
+  "registryUrl": "https://custom.registry.renovatebot.com",
+  "releases": Array [
+    Object {
+      "releaseTimestamp": "2020-01-01T01:00:00.000Z",
+      "version": "1.0.0",
+    },
+    Object {
+      "releaseTimestamp": "2020-01-01T02:00:00.000Z",
+      "version": "2.0.0",
+    },
+  ],
+}
+`;
+
+exports[`datasource/clojure/index returns releases from custom repository 2`] = `
+Array [
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "custom.registry.renovatebot.com",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://custom.registry.renovatebot.com/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "custom.registry.renovatebot.com",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://custom.registry.renovatebot.com/org/example/package/1.0.0/package-1.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "custom.registry.renovatebot.com",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://custom.registry.renovatebot.com/org/example/package/1.0.1/package-1.0.1.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "custom.registry.renovatebot.com",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://custom.registry.renovatebot.com/org/example/package/1.0.2/package-1.0.2.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "custom.registry.renovatebot.com",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://custom.registry.renovatebot.com/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "custom.registry.renovatebot.com",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://custom.registry.renovatebot.com/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+]
+`;
+
+exports[`datasource/clojure/index skips registry with invalid XML 1`] = `
+Object {
+  "display": "org.example:package",
+  "group": "org.example",
+  "homepage": "https://package.example.org/about",
+  "name": "package",
+  "registryUrl": "https://clojars.org/repo",
+  "releases": Array [
+    Object {
+      "releaseTimestamp": "2020-01-01T01:00:00.000Z",
+      "version": "1.0.0",
+    },
+    Object {
+      "releaseTimestamp": "2020-01-01T02:00:00.000Z",
+      "version": "2.0.0",
+    },
+  ],
+}
+`;
+
+exports[`datasource/clojure/index skips registry with invalid XML 2`] = `
+Array [
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "invalid_metadata_repo",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://invalid_metadata_repo/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://clojars.org/repo/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.0/package-1.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.1/package-1.0.1.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.2/package-1.0.2.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+]
+`;
+
+exports[`datasource/clojure/index skips registry with invalid metadata structure 1`] = `
+Object {
+  "display": "org.example:package",
+  "group": "org.example",
+  "homepage": "https://package.example.org/about",
+  "name": "package",
+  "registryUrl": "https://clojars.org/repo",
+  "releases": Array [
+    Object {
+      "releaseTimestamp": "2020-01-01T01:00:00.000Z",
+      "version": "1.0.0",
+    },
+    Object {
+      "releaseTimestamp": "2020-01-01T02:00:00.000Z",
+      "version": "2.0.0",
+    },
+  ],
+}
+`;
+
+exports[`datasource/clojure/index skips registry with invalid metadata structure 2`] = `
+Array [
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "invalid_metadata_repo",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://invalid_metadata_repo/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://clojars.org/repo/org/example/package/maven-metadata.xml",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.0/package-1.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.1/package-1.0.1.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/1.0.2/package-1.0.2.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "HEAD",
+    "url": "https://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "host": "clojars.org",
+      "user-agent": "https://github.com/renovatebot/renovate",
+    },
+    "method": "GET",
+    "url": "https://clojars.org/repo/org/example/package/2.0.0/package-2.0.0.pom",
+  },
+]
+`;
+
+exports[`datasource/clojure/index supports file protocol 1`] = `
+Object {
+  "display": "org.example:package",
+  "group": "org.example",
+  "homepage": "https://package.example.org/about",
+  "name": "package",
+  "registryUrl": "file:///bar",
+  "releases": Array [
+    Object {
+      "version": "1.0.0",
+    },
+    Object {
+      "version": "1.0.1",
+    },
+    Object {
+      "version": "1.0.2",
+    },
+    Object {
+      "version": "2.0.0",
+    },
+  ],
+}
+`;
+
+exports[`datasource/clojure/index supports file protocol 2`] = `
+Array [
+  Array [
+    "/bar/org/example/package/maven-metadata.xml",
+    "utf8",
+  ],
+  Array [
+    "/bar/org/example/package/2.0.0/package-2.0.0.pom",
+    "utf8",
+  ],
+]
+`;
diff --git a/lib/datasource/clojure/index.spec.ts b/lib/datasource/clojure/index.spec.ts
new file mode 100644
index 0000000000..5285ec397c
--- /dev/null
+++ b/lib/datasource/clojure/index.spec.ts
@@ -0,0 +1,265 @@
+import _fs from 'fs-extra';
+import upath from 'upath';
+import { ReleaseResult, getPkgReleases } from '..';
+import * as httpMock from '../../../test/http-mock';
+import { getName, loadFixture, mocked } from '../../../test/util';
+import * as hostRules from '../../util/host-rules';
+import { id as versioning } from '../../versioning/maven';
+import { ClojureDatasource } from '.';
+
+jest.mock('fs-extra');
+const fs = mocked(_fs);
+
+const baseUrl = 'https://clojars.org/repo';
+const baseUrlCustom = 'https://custom.registry.renovatebot.com';
+
+interface MockOpts {
+  dep?: string;
+  base?: string;
+  meta?: string | null;
+  pom?: string | null;
+  latest?: string;
+  jars?: Record<string, number> | null;
+}
+
+function mockGenericPackage(opts: MockOpts = {}) {
+  const {
+    dep = 'org.example:package',
+    base = baseUrl,
+    latest = '2.0.0',
+  } = opts;
+  const meta =
+    opts.meta === undefined
+      ? loadFixture('metadata.xml', upath.join('..', 'maven'))
+      : opts.meta;
+  const pom =
+    opts.pom === undefined
+      ? loadFixture('pom.xml', upath.join('..', 'maven'))
+      : opts.pom;
+  const jars =
+    opts.jars === undefined
+      ? {
+          '1.0.0': 200,
+          '1.0.1': 404,
+          '1.0.2': 500,
+          '2.0.0': 200,
+        }
+      : opts.jars;
+
+  const scope = httpMock.scope(base);
+
+  const [group, artifact] = dep.split(':');
+  const packagePath = `${group.replace(/\./g, '/')}/${artifact}`;
+
+  if (meta) {
+    scope.get(`/${packagePath}/maven-metadata.xml`).reply(200, meta);
+  }
+
+  if (pom) {
+    scope
+      .get(`/${packagePath}/${latest}/${artifact}-${latest}.pom`)
+      .reply(200, pom);
+  }
+
+  if (jars) {
+    Object.entries(jars).forEach(([version, status]) => {
+      const [major, minor, patch] = version
+        .split('.')
+        .map((x) => parseInt(x, 10))
+        .map((x) => (x < 10 ? `0${x}` : `${x}`));
+      const timestamp = `2020-01-01T${major}:${minor}:${patch}.000Z`;
+      scope
+        .head(`/${packagePath}/${version}/${artifact}-${version}.pom`)
+        .reply(status, '', { 'Last-Modified': timestamp });
+    });
+  }
+}
+function get(
+  depName = 'org.example:package',
+  ...registryUrls: string[]
+): Promise<ReleaseResult | null> {
+  const conf = { versioning, datasource: ClojureDatasource.id, depName };
+  return getPkgReleases(registryUrls ? { ...conf, registryUrls } : conf);
+}
+
+describe(getName(), () => {
+  beforeEach(() => {
+    hostRules.add({
+      hostType: ClojureDatasource.id,
+      matchHost: 'custom.registry.renovatebot.com',
+      token: 'abc123',
+    });
+    jest.resetAllMocks();
+    httpMock.setup();
+  });
+
+  afterEach(() => {
+    hostRules.clear();
+    httpMock.reset();
+    delete process.env.RENOVATE_EXPERIMENTAL_NO_MAVEN_POM_CHECK;
+  });
+
+  it('returns releases from custom repository', async () => {
+    mockGenericPackage({ base: baseUrlCustom });
+
+    const res = await get('org.example:package', baseUrlCustom);
+
+    expect(res).toMatchSnapshot();
+    expect(httpMock.getTrace()).toMatchSnapshot();
+  });
+
+  it('collects releases from all registry urls', async () => {
+    mockGenericPackage();
+    mockGenericPackage({
+      base: baseUrlCustom,
+      meta: loadFixture('metadata-extra.xml', upath.join('..', 'maven')),
+      latest: '3.0.0',
+      jars: { '3.0.0': 200 },
+    });
+
+    const { releases } = await get(
+      'org.example:package',
+      baseUrl,
+      baseUrlCustom
+    );
+
+    expect(releases).toMatchObject([
+      { version: '1.0.0' },
+      { version: '2.0.0' },
+      { version: '3.0.0' },
+    ]);
+    expect(httpMock.getTrace()).toMatchSnapshot();
+  });
+
+  it('falls back to next registry url', async () => {
+    mockGenericPackage();
+    httpMock
+      .scope('https://failed_repo')
+      .get('/org/example/package/maven-metadata.xml')
+      .reply(404, null);
+    httpMock
+      .scope('https://unauthorized_repo')
+      .get('/org/example/package/maven-metadata.xml')
+      .reply(403, null);
+    httpMock
+      .scope('https://empty_repo')
+      .get('/org/example/package/maven-metadata.xml')
+      .reply(200, 'non-sense');
+    httpMock
+      .scope('https://unknown_error')
+      .get('/org/example/package/maven-metadata.xml')
+      .replyWithError('unknown');
+
+    const res = await get(
+      'org.example:package',
+      'https://failed_repo/',
+      'https://unauthorized_repo/',
+      'https://empty_repo',
+      'https://unknown_error',
+      baseUrl
+    );
+
+    expect(res).toMatchSnapshot();
+    expect(httpMock.getTrace()).toMatchSnapshot();
+  });
+
+  it('ignores unsupported protocols', async () => {
+    const base = baseUrl.replace('https', 'http');
+    mockGenericPackage({ base });
+
+    const { releases } = await get(
+      'org.example:package',
+      'ftp://protocol_error_repo',
+      's3://protocol_error_repo',
+      base
+    );
+
+    expect(releases).toMatchSnapshot();
+    expect(httpMock.getTrace()).toMatchSnapshot();
+  });
+
+  it('skips registry with invalid metadata structure', async () => {
+    mockGenericPackage();
+    httpMock
+      .scope('https://invalid_metadata_repo')
+      .get('/org/example/package/maven-metadata.xml')
+      .reply(
+        200,
+        loadFixture('metadata-invalid.xml', upath.join('..', 'maven'))
+      );
+
+    const res = await get(
+      'org.example:package',
+      'https://invalid_metadata_repo',
+      baseUrl
+    );
+
+    expect(res).toMatchSnapshot();
+    expect(httpMock.getTrace()).toMatchSnapshot();
+  });
+
+  it('skips registry with invalid XML', async () => {
+    mockGenericPackage();
+    httpMock
+      .scope('https://invalid_metadata_repo')
+      .get('/org/example/package/maven-metadata.xml')
+      .reply(200, '###');
+
+    const res = await get(
+      'org.example:package',
+      'https://invalid_metadata_repo',
+      baseUrl
+    );
+
+    expect(res).toMatchSnapshot();
+    expect(httpMock.getTrace()).toMatchSnapshot();
+  });
+
+  it('handles optional slash at the end of registry url', async () => {
+    mockGenericPackage();
+    const resA = await get('org.example:package', baseUrl.replace(/\/+$/, ''));
+    mockGenericPackage();
+    const resB = await get('org.example:package', baseUrl.replace(/\/*$/, '/'));
+    expect(resA).not.toBeNull();
+    expect(resB).not.toBeNull();
+    expect(resA.releases).toEqual(resB.releases);
+    expect(httpMock.getTrace()).toMatchSnapshot();
+  });
+
+  it('returns null for invalid registryUrls', async () => {
+    const res = await get(
+      'org.example:package',
+      // eslint-disable-next-line no-template-curly-in-string
+      '${project.baseUri}../../repository/'
+    );
+    expect(res).toBeNull();
+  });
+
+  it('supports scm.url values prefixed with "scm:"', async () => {
+    const pom = loadFixture('pom.scm-prefix.xml', upath.join('..', 'maven'));
+    mockGenericPackage({ pom });
+
+    const { sourceUrl } = await get();
+
+    expect(sourceUrl).toEqual('https://github.com/example/test');
+  });
+
+  it('supports file protocol', async () => {
+    fs.exists.mockResolvedValueOnce(false);
+
+    fs.exists.mockResolvedValueOnce(true);
+    fs.readFile.mockResolvedValueOnce(
+      Buffer.from(loadFixture('metadata.xml', upath.join('..', 'maven')))
+    );
+
+    fs.exists.mockResolvedValueOnce(true);
+    fs.readFile.mockResolvedValueOnce(
+      Buffer.from(loadFixture('pom.xml', upath.join('..', 'maven')))
+    );
+
+    const res = await get('org.example:package', 'file:///foo', 'file:///bar');
+
+    expect(res).toMatchSnapshot();
+    expect(fs.readFile.mock.calls).toMatchSnapshot();
+  });
+});
diff --git a/lib/datasource/clojure/index.ts b/lib/datasource/clojure/index.ts
index 10adb16fe3..de4f1e1489 100644
--- a/lib/datasource/clojure/index.ts
+++ b/lib/datasource/clojure/index.ts
@@ -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 });
+  }
+}
diff --git a/lib/datasource/datasource.spec.ts b/lib/datasource/datasource.spec.ts
new file mode 100644
index 0000000000..1df40a4d1e
--- /dev/null
+++ b/lib/datasource/datasource.spec.ts
@@ -0,0 +1,44 @@
+import * as httpMock from '../../test/http-mock';
+import { getName } from '../../test/util';
+import { EXTERNAL_HOST_ERROR } from '../constants/error-messages';
+import { Datasource } from './datasource';
+import type { GetReleasesConfig, ReleaseResult } from './types';
+
+const exampleUrl = 'https://example.com/';
+
+class TestDatasource extends Datasource {
+  constructor() {
+    super('test');
+  }
+
+  async getReleases(
+    getReleasesConfig: GetReleasesConfig
+  ): Promise<ReleaseResult> {
+    try {
+      await this.http.get(exampleUrl);
+    } catch (err) {
+      this.handleGenericErrors(err);
+    }
+    return Promise.resolve(undefined);
+  }
+}
+
+describe(getName(), () => {
+  beforeEach(() => {
+    httpMock.setup();
+  });
+
+  afterEach(() => {
+    httpMock.reset();
+  });
+
+  it('should throw on 429', async () => {
+    const testDatasource = new TestDatasource();
+
+    httpMock.scope(exampleUrl).get('/').reply(429);
+
+    await expect(testDatasource.getReleases(undefined)).rejects.toThrow(
+      EXTERNAL_HOST_ERROR
+    );
+  });
+});
diff --git a/lib/datasource/datasource.ts b/lib/datasource/datasource.ts
new file mode 100644
index 0000000000..441bf780e8
--- /dev/null
+++ b/lib/datasource/datasource.ts
@@ -0,0 +1,51 @@
+import { ExternalHostError } from '../types/errors/external-host-error';
+import { Http } from '../util/http';
+import type { HttpError } from '../util/http/types';
+import type {
+  DatasourceApi,
+  DigestConfig,
+  GetReleasesConfig,
+  ReleaseResult,
+} from './types';
+
+export abstract class Datasource implements DatasourceApi {
+  protected constructor(public readonly id: string) {
+    this.http = new Http(id);
+  }
+
+  caching: boolean | undefined;
+
+  customRegistrySupport = true;
+
+  defaultConfig: Record<string, unknown> | undefined;
+
+  defaultRegistryUrls: string[] | undefined;
+
+  defaultVersioning: string | undefined;
+
+  registryStrategy: 'first' | 'hunt' | 'merge' | undefined = 'first';
+
+  protected http: Http;
+
+  abstract getReleases(
+    getReleasesConfig: GetReleasesConfig
+  ): Promise<ReleaseResult | null>;
+
+  getDigest?(config: DigestConfig, newValue?: string): Promise<string>;
+
+  // eslint-disable-next-line class-methods-use-this
+  handleSpecificErrors(err: HttpError): void {}
+
+  protected handleGenericErrors(err: HttpError): never {
+    this.handleSpecificErrors(err);
+    if (err.response?.statusCode !== undefined) {
+      if (
+        err.response?.statusCode === 429 ||
+        (err.response?.statusCode >= 500 && err.response?.statusCode < 600)
+      ) {
+        throw new ExternalHostError(err);
+      }
+    }
+    throw err;
+  }
+}
diff --git a/lib/datasource/index.spec.ts b/lib/datasource/index.spec.ts
index 3ccc8fee67..f244fe575c 100644
--- a/lib/datasource/index.spec.ts
+++ b/lib/datasource/index.spec.ts
@@ -5,6 +5,7 @@ import {
 } from '../constants/error-messages';
 import { ExternalHostError } from '../types/errors/external-host-error';
 import { loadModules } from '../util/modules';
+import { Datasource } from './datasource';
 import * as datasourceDocker from './docker';
 import * as datasourceGalaxy from './galaxy';
 import * as datasourceGithubTags from './github-tags';
@@ -34,19 +35,29 @@ describe(getName(), () => {
     expect(datasource.getDatasources()).toBeDefined();
     expect(datasource.getDatasourceList()).toBeDefined();
   });
-  it('validates dataource', () => {
+  it('validates datasource', () => {
     function validateDatasource(module: DatasourceApi, name: string): boolean {
       if (!module.getReleases) {
         return false;
       }
-      if (module.id !== name) {
-        return false;
+      return module.id === name;
+    }
+    function filterClassBasedDatasources(name: string): boolean {
+      return !(datasource.getDatasources().get(name) instanceof Datasource);
+    }
+    const dss = new Map(datasource.getDatasources());
+
+    for (const ds of dss.values()) {
+      if (ds instanceof Datasource) {
+        dss.delete(ds.id);
       }
-      return true;
     }
-    const dss = datasource.getDatasources();
 
-    const loadedDs = loadModules(__dirname, validateDatasource);
+    const loadedDs = loadModules(
+      __dirname,
+      validateDatasource,
+      filterClassBasedDatasources
+    );
     expect(Array.from(dss.keys())).toEqual(Object.keys(loadedDs));
 
     for (const dsName of dss.keys()) {
@@ -83,6 +94,14 @@ describe(getName(), () => {
       })
     ).toBeNull();
   });
+  it('returns class datasource', async () => {
+    expect(
+      await datasource.getPkgReleases({
+        datasource: 'cdnjs',
+        depName: null,
+      })
+    ).toBeNull();
+  });
   it('returns getDigest', async () => {
     expect(
       await datasource.getDigest({
diff --git a/lib/datasource/index.ts b/lib/datasource/index.ts
index 0f18b8596e..09d3488ded 100644
--- a/lib/datasource/index.ts
+++ b/lib/datasource/index.ts
@@ -27,7 +27,7 @@ export const getDatasourceList = (): string[] => Array.from(datasources.keys());
 
 const cacheNamespace = 'datasource-releases';
 
-function load(datasource: string): DatasourceApi {
+function getDatasourceFor(datasource: string): DatasourceApi {
   return datasources.get(datasource);
 }
 
@@ -204,7 +204,7 @@ function resolveRegistryUrls(
 }
 
 export function getDefaultVersioning(datasourceName: string): string {
-  const datasource = load(datasourceName);
+  const datasource = getDatasourceFor(datasourceName);
   return datasource.defaultVersioning || 'semver';
 }
 
@@ -212,11 +212,11 @@ async function fetchReleases(
   config: GetReleasesInternalConfig
 ): Promise<ReleaseResult | null> {
   const { datasource: datasourceName } = config;
-  if (!datasourceName || !datasources.has(datasourceName)) {
+  if (!datasourceName || getDatasourceFor(datasourceName) === undefined) {
     logger.warn('Unknown datasource: ' + datasourceName);
     return null;
   }
-  const datasource = load(datasourceName);
+  const datasource = getDatasourceFor(datasourceName);
   const registryUrls = resolveRegistryUrls(datasource, config.registryUrls);
   let dep: ReleaseResult = null;
   const registryStrategy = datasource.registryStrategy || 'hunt';
@@ -358,14 +358,14 @@ export async function getPkgReleases(
 }
 
 export function supportsDigests(config: DigestConfig): boolean {
-  return 'getDigest' in load(config.datasource);
+  return 'getDigest' in getDatasourceFor(config.datasource);
 }
 
 export function getDigest(
   config: DigestConfig,
   value?: string
 ): Promise<string | null> {
-  const datasource = load(config.datasource);
+  const datasource = getDatasourceFor(config.datasource);
   const lookupName = config.lookupName || config.depName;
   const registryUrls = resolveRegistryUrls(datasource, config.registryUrls);
   return datasource.getDigest(
@@ -377,7 +377,7 @@ export function getDigest(
 export function getDefaultConfig(
   datasource: string
 ): Promise<Record<string, unknown>> {
-  const loadedDatasource = load(datasource);
+  const loadedDatasource = getDatasourceFor(datasource);
   return Promise.resolve<Record<string, unknown>>(
     loadedDatasource?.defaultConfig || Object.create({})
   );
diff --git a/lib/manager/cdnurl/extract.ts b/lib/manager/cdnurl/extract.ts
index a3a0028273..650ea66067 100644
--- a/lib/manager/cdnurl/extract.ts
+++ b/lib/manager/cdnurl/extract.ts
@@ -1,4 +1,4 @@
-import * as datasourceCdnjs from '../../datasource/cdnjs';
+import { CdnJsDatasource } from '../../datasource/cdnjs';
 import type { PackageDependency, PackageFile } from '../types';
 
 export const cloudflareUrlRegex =
@@ -18,7 +18,7 @@ export function extractPackageFile(content: string): PackageFile {
     match = cloudflareUrlRegex.exec(rest);
 
     deps.push({
-      datasource: datasourceCdnjs.id,
+      datasource: CdnJsDatasource.id,
       depName,
       lookupName: `${depName}/${asset}`,
       currentValue,
diff --git a/lib/manager/deps-edn/extract.ts b/lib/manager/deps-edn/extract.ts
index 4d614145df..76aaadfacc 100644
--- a/lib/manager/deps-edn/extract.ts
+++ b/lib/manager/deps-edn/extract.ts
@@ -1,4 +1,4 @@
-import * as datasourceClojure from '../../datasource/clojure';
+import { ClojureDatasource } from '../../datasource/clojure';
 import { expandDepName } from '../leiningen/extract';
 import type { PackageDependency, PackageFile } from '../types';
 
@@ -16,7 +16,7 @@ export function extractPackageFile(content: string): PackageFile {
     match = regex.exec(rest);
 
     deps.push({
-      datasource: datasourceClojure.id,
+      datasource: ClojureDatasource.id,
       depName: expandDepName(depName),
       currentValue,
       registryUrls: [],
diff --git a/lib/manager/html/extract.ts b/lib/manager/html/extract.ts
index 5fa618d68f..b027836e78 100644
--- a/lib/manager/html/extract.ts
+++ b/lib/manager/html/extract.ts
@@ -1,4 +1,4 @@
-import * as datasourceCdnjs from '../../datasource/cdnjs';
+import { CdnJsDatasource } from '../../datasource/cdnjs';
 import { cloudflareUrlRegex } from '../cdnurl/extract';
 import type { PackageDependency, PackageFile } from '../types';
 
@@ -13,7 +13,7 @@ export function extractDep(tag: string): PackageDependency | null {
   }
   const { depName, currentValue, asset } = match.groups;
   const dep: PackageDependency = {
-    datasource: datasourceCdnjs.id,
+    datasource: CdnJsDatasource.id,
     depName,
     lookupName: `${depName}/${asset}`,
     currentValue,
diff --git a/lib/manager/leiningen/extract.spec.ts b/lib/manager/leiningen/extract.spec.ts
index 9dd83c4028..219fac2d98 100644
--- a/lib/manager/leiningen/extract.spec.ts
+++ b/lib/manager/leiningen/extract.spec.ts
@@ -1,5 +1,5 @@
 import { getName, loadFixture } from '../../../test/util';
-import * as datasourceClojure from '../../datasource/clojure';
+import { ClojureDatasource } from '../../datasource/clojure';
 import { extractFromVectors, extractPackageFile, trimAtKey } from './extract';
 
 const leinProjectClj = loadFixture(`project.clj`);
@@ -18,7 +18,7 @@ describe(getName(), () => {
     expect(extractFromVectors('[[]]')).toEqual([]);
     expect(extractFromVectors('[[foo/bar "1.2.3"]]')).toEqual([
       {
-        datasource: datasourceClojure.id,
+        datasource: ClojureDatasource.id,
         depName: 'foo:bar',
         currentValue: '1.2.3',
       },
@@ -27,12 +27,12 @@ describe(getName(), () => {
       extractFromVectors('[\t[foo/bar "1.2.3"]\n["foo/baz"  "4.5.6"] ]')
     ).toEqual([
       {
-        datasource: datasourceClojure.id,
+        datasource: ClojureDatasource.id,
         depName: 'foo:bar',
         currentValue: '1.2.3',
       },
       {
-        datasource: datasourceClojure.id,
+        datasource: ClojureDatasource.id,
         depName: 'foo:baz',
         currentValue: '4.5.6',
       },
diff --git a/lib/manager/leiningen/extract.ts b/lib/manager/leiningen/extract.ts
index ea712618cf..d34b8e7deb 100644
--- a/lib/manager/leiningen/extract.ts
+++ b/lib/manager/leiningen/extract.ts
@@ -1,4 +1,4 @@
-import * as datasourceClojure from '../../datasource/clojure';
+import { ClojureDatasource } from '../../datasource/clojure';
 import type { PackageDependency, PackageFile } from '../types';
 import type { ExtractContext } from './types';
 
@@ -45,7 +45,7 @@ export function extractFromVectors(
     if (artifactId && version && fileReplacePosition) {
       result.push({
         ...ctx,
-        datasource: datasourceClojure.id,
+        datasource: ClojureDatasource.id,
         depName: expandDepName(cleanStrLiteral(artifactId)),
         currentValue: cleanStrLiteral(version),
       });
-- 
GitLab