Skip to content

Commit 8245d7e

Browse files
committed
feat(badge): add endpoint badge type for external json data
1 parent 7a4563b commit 8245d7e

4 files changed

Lines changed: 135 additions & 56 deletions

File tree

docs/content/2.guide/1.features.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ Make sure to replace `TYPE` with one of the options listed below and `YOUR_PACKA
124124
- **maintenance**: NPMS.io maintenance score based on activity. :img{src="https://img.shields.io/badge/%23eab308-eab308" class="inline align-middle h-5 w-14"}
125125
- **score**: The overall NPMS.io combined score. :img{src="https://img.shields.io/badge/%233b82f6-3b82f6" class="inline align-middle h-5 w-14"}
126126
- **name**: Simple badge displaying the package name. :img{src="https://img.shields.io/badge/%2364748b-64748b" class="inline align-middle h-5 w-14"}
127+
- **endpoint**: Displays data from an external JSON endpoint via `url` query parameter. :img{src="https://img.shields.io/badge/%2364748b-64748b" class="inline align-middle h-5 w-14"}
127128

128129
#### Examples
129130

@@ -151,6 +152,10 @@ Make sure to replace `TYPE` with one of the options listed below and `YOUR_PACKA
151152
# Quality Score
152153

153154
[![Open on npmx.dev](https://npmx.dev/api/registry/badge/quality/pinia)](https://npmx.dev/package/pinia)
155+
156+
# Endpoint Badge
157+
158+
[![Stage](https://npmx.dev/api/registry/badge/endpoint/_?url=https://raw.githubusercontent.com/solidjs-community/solid-primitives/af34b836baba599c525d0db4b1c9871dd0b13f27/assets/badges/stage-2.json)](https://github.com/solidjs-community/solid-primitives)
154159
```
155160

156161
#### Customization Parameters

modules/runtime/server/cache.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,21 @@ function getMockForUrl(url: string): MockResult | null {
200200
return { data: { attestations: [] } }
201201
}
202202

203+
// GitHub raw content - return mock endpoint badge JSON
204+
if (host === 'raw.githubusercontent.com') {
205+
const stageMatch = pathname.match(/stage-(\d+)\.json$/)
206+
if (stageMatch) {
207+
return {
208+
data: {
209+
schemaVersion: 1,
210+
label: 'STAGE',
211+
message: stageMatch[1],
212+
color: '#E9DE47',
213+
},
214+
}
215+
}
216+
}
217+
203218
// Constellation API - return empty results for link queries
204219
if (host === 'constellation.microcosm.blue') {
205220
if (pathname === '/links/distinct-dids') {

server/api/registry/badge/[type]/[...pkg].get.ts

Lines changed: 96 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ const QUERY_SCHEMA = v.object({
2222
label: v.optional(SafeStringSchema),
2323
})
2424

25+
const EndpointResponseSchema = v.object({
26+
schemaVersion: v.literal(1),
27+
label: v.string(),
28+
message: v.string(),
29+
color: v.optional(v.string()),
30+
labelColor: v.optional(v.string()),
31+
})
32+
2533
const COLORS = {
2634
blue: '#3b82f6',
2735
green: '#22c55e',
@@ -248,6 +256,18 @@ async function fetchInstallSize(packageName: string, version: string): Promise<n
248256
}
249257
}
250258

259+
async function fetchEndpointBadge(url: string) {
260+
const response = await fetch(url, { headers: { Accept: 'application/json' } })
261+
const data = await response.json()
262+
const parsed = v.parse(EndpointResponseSchema, data)
263+
return {
264+
label: parsed.label,
265+
value: parsed.message,
266+
color: parsed.color,
267+
labelColor: parsed.labelColor,
268+
}
269+
}
270+
251271
const badgeStrategies = {
252272
'version': async (pkgData: globalThis.Packument, requestedVersion?: string) => {
253273
const version = requestedVersion ?? getLatestVersion(pkgData) ?? 'unknown'
@@ -388,65 +408,85 @@ export default defineCachedEventHandler(
388408
async event => {
389409
const query = getQuery(event)
390410
const typeParam = getRouterParam(event, 'type')
391-
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
392411

393-
if (pkgParamSegments.length === 0) {
394-
// TODO: throwing 404 rather than 400 as it's cacheable
395-
throw createError({ statusCode: 404, message: 'Package name is required.' })
412+
const queryParams = v.safeParse(QUERY_SCHEMA, query)
413+
const userColor = queryParams.success ? queryParams.output.color : undefined
414+
const userLabel = queryParams.success ? queryParams.output.label : undefined
415+
const labelColor = queryParams.success ? queryParams.output.labelColor : undefined
416+
const badgeStyleResult = v.safeParse(BadgeStyleSchema, query.style)
417+
const badgeStyle = badgeStyleResult.success ? badgeStyleResult.output : 'default'
418+
419+
let strategyResult: { label: string; value: string; color?: string; labelColor?: string }
420+
421+
if (typeParam === 'endpoint') {
422+
const endpointUrl = typeof query.url === 'string' ? query.url : undefined
423+
if (!endpointUrl || !endpointUrl.startsWith('https://')) {
424+
throw createError({ statusCode: 400, message: 'Missing or invalid "url" query parameter.' })
425+
}
426+
427+
try {
428+
strategyResult = await fetchEndpointBadge(endpointUrl)
429+
} catch (error: unknown) {
430+
handleApiError(error, { statusCode: 502, message: 'Failed to fetch endpoint data.' })
431+
}
432+
} else {
433+
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
434+
435+
if (pkgParamSegments.length === 0) {
436+
// TODO: throwing 404 rather than 400 as it's cacheable
437+
throw createError({ statusCode: 404, message: 'Package name is required.' })
438+
}
439+
440+
const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
441+
442+
try {
443+
const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, {
444+
packageName: rawPackageName,
445+
version: rawVersion,
446+
})
447+
448+
const showName = queryParams.success && queryParams.output.name === 'true'
449+
450+
const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam)
451+
const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version'
452+
const strategy = badgeStrategies[strategyKey as keyof typeof badgeStrategies]
453+
454+
assertValidPackageName(packageName)
455+
456+
const pkgData = await fetchNpmPackage(packageName)
457+
const result = await strategy(pkgData, requestedVersion)
458+
strategyResult = {
459+
label: showName ? packageName : result.label,
460+
value: result.value,
461+
color: result.color,
462+
}
463+
} catch (error: unknown) {
464+
handleApiError(error, {
465+
statusCode: 502,
466+
message: ERROR_NPM_FETCH_FAILED,
467+
})
468+
}
396469
}
397470

398-
const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
399-
400-
try {
401-
const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, {
402-
packageName: rawPackageName,
403-
version: rawVersion,
404-
})
405-
406-
const queryParams = v.safeParse(QUERY_SCHEMA, query)
407-
const userColor = queryParams.success ? queryParams.output.color : undefined
408-
const labelColor = queryParams.success ? queryParams.output.labelColor : undefined
409-
const showName = queryParams.success && queryParams.output.name === 'true'
410-
const userLabel = queryParams.success ? queryParams.output.label : undefined
411-
const badgeStyleResult = v.safeParse(BadgeStyleSchema, query.style)
412-
const badgeStyle = badgeStyleResult.success ? badgeStyleResult.output : 'default'
413-
414-
const badgeTypeResult = v.safeParse(BadgeTypeSchema, typeParam)
415-
const strategyKey = badgeTypeResult.success ? badgeTypeResult.output : 'version'
416-
const strategy = badgeStrategies[strategyKey as keyof typeof badgeStrategies]
417-
418-
assertValidPackageName(packageName)
419-
420-
const pkgData = await fetchNpmPackage(packageName)
421-
const strategyResult = await strategy(pkgData, requestedVersion)
422-
423-
const finalLabel = userLabel ? userLabel : showName ? packageName : strategyResult.label
424-
const finalValue = strategyResult.value
425-
426-
const rawColor = userColor ?? strategyResult.color
427-
const finalColor = rawColor?.startsWith('#') ? rawColor : `#${rawColor}`
428-
429-
const defaultLabelColor = badgeStyle === 'shieldsio' ? '#555' : '#0a0a0a'
430-
const rawLabelColor = labelColor ?? defaultLabelColor
431-
const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
432-
433-
const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg
434-
const svg = renderFn({ finalColor, finalLabel, finalLabelColor, finalValue })
435-
436-
setHeader(event, 'Content-Type', 'image/svg+xml')
437-
setHeader(
438-
event,
439-
'Cache-Control',
440-
`public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, s-maxage=${CACHE_MAX_AGE_ONE_HOUR}`,
441-
)
442-
443-
return svg
444-
} catch (error: unknown) {
445-
handleApiError(error, {
446-
statusCode: 502,
447-
message: ERROR_NPM_FETCH_FAILED,
448-
})
449-
}
471+
const finalLabel = userLabel ?? strategyResult.label
472+
const finalValue = strategyResult.value
473+
const rawColor = userColor ?? strategyResult.color ?? COLORS.slate
474+
const finalColor = rawColor.startsWith('#') ? rawColor : `#${rawColor}`
475+
const defaultLabelColor = badgeStyle === 'shieldsio' ? '#555' : '#0a0a0a'
476+
const rawLabelColor = labelColor ?? strategyResult.labelColor ?? defaultLabelColor
477+
const finalLabelColor = rawLabelColor.startsWith('#') ? rawLabelColor : `#${rawLabelColor}`
478+
479+
const renderFn = badgeStyle === 'shieldsio' ? renderShieldsBadgeSvg : renderDefaultBadgeSvg
480+
const svg = renderFn({ finalColor, finalLabel, finalLabelColor, finalValue })
481+
482+
setHeader(event, 'Content-Type', 'image/svg+xml')
483+
setHeader(
484+
event,
485+
'Cache-Control',
486+
`public, max-age=${CACHE_MAX_AGE_ONE_HOUR}, s-maxage=${CACHE_MAX_AGE_ONE_HOUR}`,
487+
)
488+
489+
return svg
450490
},
451491
{
452492
maxAge: CACHE_MAX_AGE_ONE_HOUR,

test/e2e/badge.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,23 @@ test.describe('badge API', () => {
153153

154154
expect(response.status()).toBe(404)
155155
})
156+
157+
test('endpoint badge renders from external JSON', async ({ page, baseURL }) => {
158+
const endpointUrl = encodeURIComponent(
159+
'https://raw.githubusercontent.com/solidjs-community/solid-primitives/main/assets/badges/stage-2.json',
160+
)
161+
const url = toLocalUrl(baseURL, `/api/registry/badge/endpoint/_?url=${endpointUrl}`)
162+
const { body, response } = await fetchBadge(page, url)
163+
164+
expect(response.status()).toBe(200)
165+
expect(body).toContain('STAGE')
166+
expect(body).toContain('>2<')
167+
})
168+
169+
test('endpoint badge without url returns 400', async ({ page, baseURL }) => {
170+
const url = toLocalUrl(baseURL, '/api/registry/badge/endpoint/_')
171+
const { response } = await fetchBadge(page, url)
172+
173+
expect(response.status()).toBe(400)
174+
})
156175
})

0 commit comments

Comments
 (0)