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
5 changes: 5 additions & 0 deletions .changeset/met-v1-1-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@refkit/provider-met': patch
---

Met: search through `/public/collection/v1.1/search`. The Met retired `/v1/search` on 2026-10-01, and it now answers 410 Gone, so every Met search failed. The v1.1 endpoint takes the same filters and returns one page of object IDs per request, so the provider now asks for exactly the page it needs with `offset` and `limit` instead of downloading every matching ID and slicing it. Object records still come from `/v1/objects/{id}`. Every `providerOptions` filter is forwarded unchanged.
52 changes: 52 additions & 0 deletions packages/provider-met/src/__tests__/met.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,58 @@ describe('met provider', () => {
expect(refs).toEqual([])
})

// /v1/search was retired on 2026-10-01 (410 Gone). /v1.1/search takes the same filters but
// returns one page of ids per request (offset/limit); object records stay on /v1/objects.
it('asks the paginated v1.1 search for one page and reads objects from v1', async () => {
const urls: string[] = []
const inner = ctxRouting(SEARCH, { '436535': OBJ_PD, '999999': OBJ_COPYRIGHT })
const ctx: ProviderContext = {
fetch: (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
urls.push(String(input))
return inner.fetch(input, init)
}) as typeof fetch,
}
await met().search({ text: 'wheat', modalities: ['image'], limit: 5 }, ctx)
const search = new URL(urls[0])
expect(search.pathname).toBe('/public/collection/v1.1/search')
expect(search.searchParams.get('offset')).toBe('0')
expect(search.searchParams.get('limit')).toBe('5')
expect(urls.slice(1).map(u => new URL(u).pathname).sort()).toEqual([
'/public/collection/v1/objects/436535',
'/public/collection/v1/objects/999999',
])
})

it('pages upstream with offset and maps every id the page returns', async () => {
let searchUrl = ''
const inner = ctxRouting({ total: 40, objectIDs: [436535, 1] }, { '436535': OBJ_PD, '1': { ...OBJ_PD, objectID: 1, title: 'Second' } })
const ctx: ProviderContext = {
fetch: (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
if (String(input).includes('/search')) searchUrl = String(input)
return inner.fetch(input, init)
}) as typeof fetch,
}
const refs = await met().search({ text: 'wheat', modalities: ['image'], limit: 2, controls: { page: 3 } }, ctx)
expect(new URL(searchUrl).searchParams.get('offset')).toBe('4')
expect(new URL(searchUrl).searchParams.get('limit')).toBe('2')
expect(refs.map(r => r.title)).toEqual(['Wheat Field with Cypresses', 'Second'])
})

it('never fetches more objects than one page even if upstream returns extra ids', async () => {
let objectFetches = 0
const ids = [436535, 2, 3, 4, 5, 6]
const inner = ctxRouting({ total: 6, objectIDs: ids }, Object.fromEntries(ids.map(id => [String(id), { ...OBJ_PD, objectID: id }])))
const ctx: ProviderContext = {
fetch: (async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
if (String(input).includes('/objects/')) objectFetches++
return inner.fetch(input, init)
}) as typeof fetch,
}
const refs = await met().search({ text: 'wheat', modalities: ['image'], limit: 3 }, ctx)
expect(objectFetches).toBe(3)
expect(refs).toHaveLength(3)
})

it('forwards documented Met search options', async () => {
let searchUrl = ''
const ctx: ProviderContext = {
Expand Down
15 changes: 10 additions & 5 deletions packages/provider-met/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ export interface MetSearchOptions {
}

const BASE = 'https://collectionapi.metmuseum.org/public/collection/v1'
// /v1/search (every matching id in one response) was retired on 2026-10-01 and now answers
// 410. /v1.1/search takes the same filters plus offset/limit and returns one page of ids;
// upstream caps limit at 500 and serves only the first 10,000 results of a query.
const SEARCH = 'https://collectionapi.metmuseum.org/public/collection/v1.1/search'

interface MetSearchResponse { total: number; objectIDs: number[] | null }
interface MetObject {
Expand Down Expand Up @@ -81,9 +85,12 @@ export function met(config: MetConfig = {}) {
description: 'Open Access artworks from the Metropolitan Museum of Art',
capabilities: { controls: ['page'] },
async search(q: NormalizedQuery, ctx: ProviderContext): Promise<EmittedReference[]> {
const searchUrl = new URL(`${BASE}/search`)
const n = Math.min(config.maxObjects ?? q.limit ?? 12, 30)
const searchUrl = new URL(SEARCH)
searchUrl.searchParams.set('q', q.text)
searchUrl.searchParams.set('hasImages', 'true')
searchUrl.searchParams.set('offset', String(offsetForPage(q.controls?.page, n) ?? 0))
searchUrl.searchParams.set('limit', String(n))
const opts = q.providerOptions as MetSearchOptions | undefined
setIfBoolean(searchUrl, 'isHighlight', opts?.isHighlight)
setIfBoolean(searchUrl, 'title', opts?.title)
Expand All @@ -98,10 +105,8 @@ export function met(config: MetConfig = {}) {
const res = await ctx.fetch(searchUrl.toString(), { signal: ctx.signal })
const { objectIDs } = await okJson<MetSearchResponse>(res, 'met search')
if (!objectIDs || objectIDs.length === 0) return []
const n = Math.min(config.maxObjects ?? q.limit ?? 12, 30)
// the search endpoint returns every matching id — page = a window over that list
const from = offsetForPage(q.controls?.page, n) ?? 0
const objects = await Promise.all(objectIDs.slice(from, from + n).map(async (id) => {
// objectIDs is already the requested page; the slice only bounds the N+1 object fan-out
const objects = await Promise.all(objectIDs.slice(0, n).map(async (id) => {
try {
const r = await ctx.fetch(`${BASE}/objects/${id}`, { signal: ctx.signal })
if (!r.ok) return null
Expand Down
Loading