feat: integration search bar
This commit is contained in:
1 parent
a8fea8d900
commit
25d9c30b8a
22 files changed
+2558
-41
No files matched your search
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildSearchIndex,
|
||||
firstSentence,
|
||||
highlightSegments,
|
||||
queryTerms,
|
||||
searchIndex,
|
||||
suggestedHits,
|
||||
type EndpointEntry,
|
||||
type SearchHit,
|
||||
} from '../apiSearch'
|
||||
|
||||
const index = buildSearchIndex()
|
||||
|
||||
function routes(hits: SearchHit[]) {
|
||||
return hits.map((hit) =>
|
||||
hit.entry.kind === 'endpoint'
|
||||
? `${hit.entry.method} ${hit.entry.path}`
|
||||
: `section ${hit.entry.title}`,
|
||||
)
|
||||
}
|
||||
|
||||
function search(query: string) {
|
||||
return searchIndex(index, query)
|
||||
}
|
||||
|
||||
describe('firstSentence', () => {
|
||||
it.each([
|
||||
['Get one article. Drafts answer 404 to anonymous callers.', 'Get one article.'],
|
||||
['Rename an image', 'Rename an image'],
|
||||
['Upload an image (multipart).', 'Upload an image (multipart).'],
|
||||
])('cuts %s', (text, expected) => {
|
||||
expect(firstSentence(text)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryTerms', () => {
|
||||
it('splits on spaces, trims and lowercases', () => {
|
||||
expect(queryTerms(' POST Media ')).toEqual(['post', 'media'])
|
||||
expect(queryTerms(' ')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('index', () => {
|
||||
it('covers documented, session only and public auth routes', () => {
|
||||
const keys = index.endpoints.map((entry) => entry.key)
|
||||
expect(keys).toContain('ep:med-merge')
|
||||
expect(keys).toContain('session:POST /v1/auth/login')
|
||||
expect(keys).toContain('public:GET /healthz')
|
||||
expect(index.sections.map((section) => section.sectionId)).toEqual([
|
||||
'auth',
|
||||
'articles',
|
||||
'seo',
|
||||
'media',
|
||||
'users',
|
||||
'settings',
|
||||
'sites',
|
||||
'authors',
|
||||
'images',
|
||||
'session',
|
||||
'errors',
|
||||
'notes',
|
||||
])
|
||||
})
|
||||
|
||||
it('never indexes code samples or response examples', () => {
|
||||
expect(search('rcms_kf3x').endpoints.map((hit) => hit.entry.key)).toEqual([])
|
||||
expect(search('"total"').endpoints).toEqual([])
|
||||
expect(search('curl').endpoints).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('matching', () => {
|
||||
it('matches terms case-insensitively as substrings', () => {
|
||||
expect(routes(search('MEDIA').endpoints)).toContain('POST /v1/media/merge')
|
||||
expect(routes(search('edia').endpoints)).toContain('POST /v1/media/merge')
|
||||
})
|
||||
|
||||
it('does not match a misspelled term', () => {
|
||||
const result = search('mdia')
|
||||
expect(result.endpoints).toEqual([])
|
||||
expect(result.sections).toEqual([])
|
||||
})
|
||||
|
||||
it('requires every term in the same endpoint', () => {
|
||||
const result = routes(search('post media').endpoints)
|
||||
expect(result).toContain('POST /v1/media/')
|
||||
expect(result.every((route) => !route.startsWith('GET'))).toBe(true)
|
||||
expect(routes(search('patch user').endpoints)).toContain('PATCH /v1/user/me')
|
||||
})
|
||||
|
||||
it('ignores extra spaces', () => {
|
||||
expect(routes(search(' post media ').endpoints)).toEqual(
|
||||
routes(search('post media').endpoints),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns nothing for an empty query', () => {
|
||||
expect(search(' ')).toEqual({ endpoints: [], sections: [] })
|
||||
})
|
||||
|
||||
it('matches the access chip', () => {
|
||||
const owners = search('OWNER ONLY').endpoints.map((hit) => hit.entry as EndpointEntry)
|
||||
expect(owners.map((entry) => entry.path)).toEqual(
|
||||
expect.arrayContaining(['/v1/user/invite', '/v1/user/invite/{id}']),
|
||||
)
|
||||
expect(
|
||||
owners.every((entry) => entry.access === 'Owner only' || entry.path.length > 0),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches a body field and reports it as the second line', () => {
|
||||
const hits = search('keep_id').endpoints
|
||||
expect(routes(hits)).toEqual(['POST /v1/media/merge'])
|
||||
expect(hits[0]!.via).toEqual({ kind: 'body', text: 'keep_id' })
|
||||
expect(search('keep_id').sections).toEqual([])
|
||||
})
|
||||
|
||||
it('matches a query parameter and labels it', () => {
|
||||
const hit = search('hash').endpoints.find(
|
||||
(item) => (item.entry as EndpointEntry).path === '/v1/media/',
|
||||
)
|
||||
expect(hit!.via).toEqual({ kind: 'query', text: 'hash' })
|
||||
})
|
||||
|
||||
it('matches an error code or message and reports the first error', () => {
|
||||
const hits = search('404').endpoints
|
||||
expect(hits.length).toBeGreaterThan(6)
|
||||
const withError = hits.find((hit) => hit.via?.kind === 'error')!
|
||||
expect(withError.via!.text.startsWith('404 ')).toBe(true)
|
||||
expect(routes(search('404').sections)).toEqual(['section Errors'])
|
||||
})
|
||||
|
||||
it('shows no second line for a path, description, method or chip match', () => {
|
||||
for (const query of ['merge', 'identical copies', 'patch', 'verified user']) {
|
||||
expect(search(query).endpoints.some((hit) => hit.via === null)).toBe(true)
|
||||
}
|
||||
expect(search('merge').endpoints[0]!.via).toBeNull()
|
||||
})
|
||||
|
||||
it('lists path and description matches first, then catalogue order', () => {
|
||||
const hits = search('token').endpoints
|
||||
const firstIndirect = hits.findIndex((hit) => hit.via !== null)
|
||||
if (firstIndirect !== -1) {
|
||||
expect(hits.slice(0, firstIndirect).every((hit) => hit.via === null)).toBe(true)
|
||||
expect(hits.slice(firstIndirect).every((hit) => hit.via !== null)).toBe(true)
|
||||
}
|
||||
const keys = index.endpoints.map((entry) => entry.key)
|
||||
const media = search('media').endpoints.filter((hit) => hit.via === null)
|
||||
const positions = media.map((hit) => keys.indexOf(hit.entry.key))
|
||||
expect(positions).toEqual([...positions].sort((a, b) => a - b))
|
||||
})
|
||||
|
||||
it('puts a field-only match after the path matches', () => {
|
||||
const hits = search('keep_id media').endpoints
|
||||
expect(routes(hits)).toEqual(['POST /v1/media/merge'])
|
||||
const mixed = search('title').endpoints
|
||||
const firstIndirect = mixed.findIndex((hit) => hit.via !== null)
|
||||
expect(firstIndirect === -1 || mixed.slice(firstIndirect).every((hit) => hit.via)).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('finds sections by title, description and keywords', () => {
|
||||
expect(routes(search('media').sections)).toEqual(['section Media'])
|
||||
expect(search('media').sections[0]!.entry).toMatchObject({ description: '11 routes' })
|
||||
const login = search('login')
|
||||
expect(routes(login.endpoints)).toEqual(['POST /v1/auth/login'])
|
||||
expect(routes(login.sections)).toEqual(['section Session only'])
|
||||
expect(login.sections[0]!.entry).toMatchObject({
|
||||
locked: true,
|
||||
description: '25 routes, not available to API keys',
|
||||
})
|
||||
})
|
||||
|
||||
it('flags session-only routes and sends them to the Session only section', () => {
|
||||
const entry = search('login').endpoints[0]!.entry as EndpointEntry
|
||||
expect(entry).toMatchObject({
|
||||
session: true,
|
||||
access: 'Session only',
|
||||
description: 'Log in',
|
||||
cardId: null,
|
||||
sectionId: 'session',
|
||||
})
|
||||
})
|
||||
|
||||
it('finds nothing for xyz', () => {
|
||||
expect(search('xyz')).toEqual({ endpoints: [], sections: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestions', () => {
|
||||
it('lists the five suggested rows in order', () => {
|
||||
expect(routes(suggestedHits(index))).toEqual([
|
||||
'section Authentication',
|
||||
'POST /v1/media/',
|
||||
'section Errors',
|
||||
'GET /v1/article/',
|
||||
'PATCH /v1/user/me',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('highlightSegments', () => {
|
||||
it('marks every occurrence of every term', () => {
|
||||
expect(highlightSegments('POST /v1/media/media', ['media'])).toEqual([
|
||||
{ text: 'POST /v1/', marked: false },
|
||||
{ text: 'media', marked: true },
|
||||
{ text: '/', marked: false },
|
||||
{ text: 'media', marked: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('is case-insensitive and escapes regex characters', () => {
|
||||
expect(highlightSegments('Get {id}', ['get', '{id}'])).toEqual([
|
||||
{ text: 'Get', marked: true },
|
||||
{ text: ' ', marked: false },
|
||||
{ text: '{id}', marked: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the text unchanged without terms', () => {
|
||||
expect(highlightSegments('Hello', [])).toEqual([{ text: 'Hello', marked: false }])
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,8 @@ export const CHIP_CLASS = {
|
||||
const METHOD_BASE =
|
||||
'h-5.5 w-14 shrink-0 justify-center rounded-none px-0 py-0 font-mono text-xs font-medium ring-0 border'
|
||||
|
||||
export const SESSION_METHOD_CLASS = `${METHOD_BASE} border-dashed border-accented bg-transparent text-muted`
|
||||
|
||||
export const METHOD_CLASS: Record<HttpMethod, string> = {
|
||||
GET: `${METHOD_BASE} border-inverted bg-inverted text-inverted`,
|
||||
POST: `${METHOD_BASE} border-success bg-(--rc-success-soft) text-success`,
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { t, translate } from '@/i18n'
|
||||
import {
|
||||
API_GROUPS,
|
||||
ERROR_ROWS,
|
||||
LOGIN_FAMILY_CAPTION,
|
||||
PUBLIC_AUTH_ROWS,
|
||||
SESSION_GROUPS,
|
||||
type HttpMethod,
|
||||
} from '@/data/apiCatalogue'
|
||||
|
||||
export const ENDPOINT_CAP = 6
|
||||
|
||||
export type MatchKind = 'query' | 'body' | 'error'
|
||||
|
||||
export interface SearchMatch {
|
||||
kind: MatchKind
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface EndpointEntry {
|
||||
kind: 'endpoint'
|
||||
key: string
|
||||
method: HttpMethod
|
||||
path: string
|
||||
description: string
|
||||
access: string
|
||||
session: boolean
|
||||
cardId: string | null
|
||||
sectionId: string
|
||||
fields: SearchMatch[]
|
||||
errors: SearchMatch[]
|
||||
}
|
||||
|
||||
export interface SectionEntry {
|
||||
kind: 'section'
|
||||
key: string
|
||||
sectionId: string
|
||||
title: string
|
||||
description: string
|
||||
locked: boolean
|
||||
keywords: string
|
||||
}
|
||||
|
||||
export type SearchEntry = EndpointEntry | SectionEntry
|
||||
|
||||
export interface SearchHit {
|
||||
entry: SearchEntry
|
||||
via: SearchMatch | null
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
endpoints: SearchHit[]
|
||||
sections: SearchHit[]
|
||||
}
|
||||
|
||||
export interface HighlightSegment {
|
||||
text: string
|
||||
marked: boolean
|
||||
}
|
||||
|
||||
export interface SearchIndex {
|
||||
endpoints: EndpointEntry[]
|
||||
sections: SectionEntry[]
|
||||
}
|
||||
|
||||
const SUGGESTED_KEYS = ['section:auth', 'ep:med-up', 'section:errors', 'ep:art-list', 'ep:usr-me']
|
||||
|
||||
export function firstSentence(text: string) {
|
||||
const end = text.search(/\.(\s|$)/)
|
||||
return end === -1 ? text : text.slice(0, end + 1)
|
||||
}
|
||||
|
||||
export function queryTerms(query: string) {
|
||||
return query.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
function endpointEntries(): EndpointEntry[] {
|
||||
const documented = API_GROUPS.flatMap((group) =>
|
||||
group.endpoints.map<EndpointEntry>((endpoint) => ({
|
||||
kind: 'endpoint',
|
||||
key: `ep:${endpoint.id}`,
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
description: firstSentence(translate(endpoint.description)),
|
||||
access: translate(endpoint.access),
|
||||
session: false,
|
||||
cardId: endpoint.id,
|
||||
sectionId: group.key,
|
||||
fields: [
|
||||
...endpoint.query.map<SearchMatch>((field) => ({
|
||||
kind: 'query',
|
||||
text: field.name,
|
||||
})),
|
||||
...endpoint.body.map<SearchMatch>((field) => ({ kind: 'body', text: field.name })),
|
||||
],
|
||||
errors: endpoint.errors.map<SearchMatch>((error) => ({
|
||||
kind: 'error',
|
||||
text: `${error.status} ${translate(error.caption)}`,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
const sessionOnly = SESSION_GROUPS.flatMap((group) =>
|
||||
group.rows.map<EndpointEntry>((row) => ({
|
||||
kind: 'endpoint',
|
||||
key: `session:${row.method} ${row.path}`,
|
||||
method: row.method,
|
||||
path: row.path,
|
||||
description: firstSentence(
|
||||
translate(row.description.split(`. ${LOGIN_FAMILY_CAPTION}`)[0]!),
|
||||
),
|
||||
access: t('Session only'),
|
||||
session: true,
|
||||
cardId: null,
|
||||
sectionId: 'session',
|
||||
fields: [],
|
||||
errors: [],
|
||||
})),
|
||||
)
|
||||
const publicAuth = PUBLIC_AUTH_ROWS.map<EndpointEntry>((row) => ({
|
||||
kind: 'endpoint',
|
||||
key: `public:${row.method} ${row.path}`,
|
||||
method: row.method,
|
||||
path: row.path,
|
||||
description: firstSentence(translate(row.description)),
|
||||
access: t('Public'),
|
||||
session: false,
|
||||
cardId: null,
|
||||
sectionId: 'session',
|
||||
fields: [],
|
||||
errors: [],
|
||||
}))
|
||||
return [...documented, ...sessionOnly, ...publicAuth]
|
||||
}
|
||||
|
||||
function routeCount(count: number) {
|
||||
return count === 1 ? t('{0} route', [count]) : t('{0} routes', [count])
|
||||
}
|
||||
|
||||
function sectionEntries(): SectionEntry[] {
|
||||
const sessionRoutes = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
|
||||
const errorWords = ERROR_ROWS.map((row) => `${row.status} ${translate(row.meaning)}`).join(' ')
|
||||
const section = (
|
||||
id: string,
|
||||
title: string,
|
||||
description: string,
|
||||
keywords = '',
|
||||
locked = false,
|
||||
): SectionEntry => ({
|
||||
kind: 'section',
|
||||
key: `section:${id}`,
|
||||
sectionId: id,
|
||||
title,
|
||||
description,
|
||||
locked,
|
||||
keywords,
|
||||
})
|
||||
return [
|
||||
section(
|
||||
'auth',
|
||||
t('Authentication'),
|
||||
t('Public access and API keys'),
|
||||
'bearer api key rcms rate limit 429 base url public',
|
||||
),
|
||||
...API_GROUPS.map((group) =>
|
||||
section(group.key, translate(group.title), routeCount(group.endpoints.length)),
|
||||
),
|
||||
section(
|
||||
'images',
|
||||
t('Images and variants'),
|
||||
t('The ?v= parameter'),
|
||||
'variant image width height format quality',
|
||||
),
|
||||
section(
|
||||
'session',
|
||||
t('Session only'),
|
||||
t('{0} routes, not available to API keys', [sessionRoutes]),
|
||||
'login log in register password real session',
|
||||
true,
|
||||
),
|
||||
section(
|
||||
'errors',
|
||||
t('Errors'),
|
||||
t('Status codes and messages'),
|
||||
`${errorWords} status error plain text`,
|
||||
),
|
||||
section(
|
||||
'notes',
|
||||
t('Notes'),
|
||||
t('Images, CORS and ?format='),
|
||||
'cors origins format markdown html json body_format',
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
export function buildSearchIndex(): SearchIndex {
|
||||
return { endpoints: endpointEntries(), sections: sectionEntries() }
|
||||
}
|
||||
|
||||
function primaryText(entry: EndpointEntry) {
|
||||
return `${entry.method} ${entry.path} ${entry.description} ${entry.access}`.toLowerCase()
|
||||
}
|
||||
|
||||
function viaMatch(entry: EndpointEntry, term: string): SearchMatch | null | undefined {
|
||||
if (primaryText(entry).includes(term)) {
|
||||
return null
|
||||
}
|
||||
const field = entry.fields.find((item) => item.text.toLowerCase().includes(term))
|
||||
if (field) {
|
||||
return field
|
||||
}
|
||||
return entry.errors.find((item) => item.text.toLowerCase().includes(term))
|
||||
}
|
||||
|
||||
function matchEndpoint(entry: EndpointEntry, terms: string[]): SearchHit | null {
|
||||
let via: SearchMatch | null = null
|
||||
for (const term of terms) {
|
||||
const found = viaMatch(entry, term)
|
||||
if (found === undefined) {
|
||||
return null
|
||||
}
|
||||
via = via ?? found
|
||||
}
|
||||
return { entry, via }
|
||||
}
|
||||
|
||||
function matchSection(entry: SectionEntry, terms: string[]): SearchHit | null {
|
||||
const text = `${entry.title} ${entry.description} ${entry.keywords}`.toLowerCase()
|
||||
return terms.every((term) => text.includes(term)) ? { entry, via: null } : null
|
||||
}
|
||||
|
||||
export function suggestedHits(index: SearchIndex): SearchHit[] {
|
||||
const entries: SearchEntry[] = [...index.endpoints, ...index.sections]
|
||||
return SUGGESTED_KEYS.flatMap((key) => {
|
||||
const entry = entries.find((item) => item.key === key)
|
||||
return entry ? [{ entry, via: null }] : []
|
||||
})
|
||||
}
|
||||
|
||||
export function searchIndex(index: SearchIndex, query: string): SearchResult {
|
||||
const terms = queryTerms(query)
|
||||
if (terms.length === 0) {
|
||||
return { endpoints: [], sections: [] }
|
||||
}
|
||||
const hits = index.endpoints.flatMap((entry) => matchEndpoint(entry, terms) ?? [])
|
||||
const direct = hits.filter((hit) => hit.via === null)
|
||||
const indirect = hits.filter((hit) => hit.via !== null)
|
||||
return {
|
||||
endpoints: [...direct, ...indirect],
|
||||
sections: index.sections.flatMap((entry) => matchSection(entry, terms) ?? []),
|
||||
}
|
||||
}
|
||||
|
||||
function escapePattern(term: string) {
|
||||
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
export function highlightSegments(text: string, terms: string[]): HighlightSegment[] {
|
||||
if (terms.length === 0) {
|
||||
return [{ text, marked: false }]
|
||||
}
|
||||
const pattern = new RegExp(`(${terms.map(escapePattern).join('|')})`, 'gi')
|
||||
const lowered = new Set(terms)
|
||||
return text
|
||||
.split(pattern)
|
||||
.filter((part) => part !== '')
|
||||
.map((part) => ({ text: part, marked: lowered.has(part.toLowerCase()) }))
|
||||
}
|
||||
Reference in new issue
Block a user