Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions services/docker/docker-last-updated.service.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import Joi from 'joi'
import { renderDateBadge } from '../date.js'
import { nonNegativeInteger } from '../validators.js'
import { BaseJsonService, NotFound, pathParams } from '../index.js'
import { buildDockerUrl, getDockerHubUser } from './docker-helpers.js'
import { fetch } from './docker-hub-common-fetch.js'

const tagSchema = Joi.object({
last_updated: Joi.string().required(),
}).required()

const newestSchema = Joi.object({
count: nonNegativeInteger.required(),
results: Joi.array()
.items(
Joi.object({
last_updated: Joi.string().required(),
}),
)
.required(),
}).required()

export default class DockerLastUpdated extends BaseJsonService {
static category = 'activity'
static route = buildDockerUrl('last-updated', true)

static auth = {
userKey: 'dockerhub_username',
passKey: 'dockerhub_pat',
authorizedOrigins: [
'https://hub.docker.com',
'https://registry.hub.docker.com',
],
isRequired: false,
}

static openApi = {
'/docker/last-updated/{user}/{repo}': {
get: {
summary: 'Docker Image Last Updated',
description:
'Shows when the most recently updated tag of a Docker Hub image was last pushed.',
parameters: pathParams(
{ name: 'user', example: '_' },
{ name: 'repo', example: 'alpine' },
),
},
},
'/docker/last-updated/{user}/{repo}/{tag}': {
get: {
summary: 'Docker Image Last Updated (tag)',
description:
'Shows when a specific Docker Hub image tag was last pushed.',
parameters: pathParams(
{ name: 'user', example: '_' },
{ name: 'repo', example: 'alpine' },
{ name: 'tag', example: 'latest' },
),
},
},
}

static _cacheLength = 900

static defaultBadgeData = { label: 'last updated' }

static render({ date }) {
return renderDateBadge(date)
}

async fetch({ user, repo, tag }) {
return await fetch(this, {
schema: tag ? tagSchema : newestSchema,
url: `https://registry.hub.docker.com/v2/repositories/${getDockerHubUser(
user,
)}/${repo}/tags${tag ? `/${tag}` : '?page_size=1&ordering=last_updated'}`,
httpErrors: { 404: 'repository or tag not found' },
})
}

transform({ tag, data }) {
if (tag) {
return { date: data.last_updated }
}
// 404 from the Hub API already maps to "repository or tag not found".
// An empty results list is a 200 for a repo that exists but has no tags yet.
if (data.count === 0 || data.results.length === 0) {
throw new NotFound({ prettyMessage: 'no tags found' })
}
return { date: data.results[0].last_updated }
}

async handle({ user, repo, tag }) {
const data = await this.fetch({ user, repo, tag })
const { date } = this.transform({ tag, data })
return this.constructor.render({ date })
}
}
46 changes: 46 additions & 0 deletions services/docker/docker-last-updated.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { expect } from 'chai'
import { test, given } from 'sazerac'
import { NotFound } from '../index.js'
import DockerLastUpdated from './docker-last-updated.service.js'

describe('DockerLastUpdated', function () {
test(DockerLastUpdated.prototype.transform, () => {
given({
tag: '',
data: {
count: 1,
results: [{ last_updated: '2026-08-06T20:24:42.17447Z' }],
},
}).expect({
date: '2026-08-06T20:24:42.17447Z',
})
given({
tag: 'latest',
data: { last_updated: '2026-06-16T02:24:22.835730996Z' },
}).expect({
date: '2026-06-16T02:24:22.835730996Z',
})
})

it('throws NotFound when repository has no tags', function () {
expect(() => {
DockerLastUpdated.prototype.transform({
tag: '',
data: { count: 0, results: [] },
})
})
.to.throw(NotFound)
.with.property('prettyMessage', 'no tags found')
})

it('throws NotFound when results list is empty', function () {
expect(() => {
DockerLastUpdated.prototype.transform({
tag: '',
data: { count: 1, results: [] },
})
})
.to.throw(NotFound)
.with.property('prettyMessage', 'no tags found')
})
})
46 changes: 46 additions & 0 deletions services/docker/docker-last-updated.tester.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { isFormattedDate } from '../test-validators.js'
import { createServiceTester } from '../tester.js'

export const t = await createServiceTester()

t.create('docker last updated (valid, library)')
.get('/_/alpine.json')
.expectBadge({
label: 'last updated',
message: isFormattedDate,
})

t.create('docker last updated (valid, library with tag)')
.get('/_/alpine/latest.json')
.expectBadge({
label: 'last updated',
message: isFormattedDate,
})

t.create('docker last updated (valid, user)')
.get('/datadog/dogstatsd.json')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Love this example 🐶

.expectBadge({
label: 'last updated',
message: isFormattedDate,
})

t.create('docker last updated (valid, user with tag)')
.get('/jrottenberg/ffmpeg/3.2-alpine.json')
.expectBadge({
label: 'last updated',
message: isFormattedDate,
})

t.create('docker last updated (invalid, incorrect tag)')
.get('/_/alpine/wrong-tag.json')
.expectBadge({
label: 'last updated',
message: 'repository or tag not found',
})

t.create('docker last updated (invalid, unknown repository)')
.get('/_/not-a-real-repo.json')
.expectBadge({
label: 'last updated',
message: 'repository or tag not found',
})
Loading