diff --git a/deno.lock b/deno.lock index 90b7a6be..cad2a679 100644 --- a/deno.lock +++ b/deno.lock @@ -200,6 +200,7 @@ }, "projects/openapi": { "dependencies": [ + "jsr:@std/assert@^1.0.13", "npm:@anatine/zod-openapi@1.12.0", "npm:@hono/swagger-ui@0.5", "npm:@seriousme/openapi-schema-validator@^2.3.1", diff --git a/projects/api/src/contracts/search/index.ts b/projects/api/src/contracts/search/index.ts index 2278f20a..0f7179ef 100644 --- a/projects/api/src/contracts/search/index.ts +++ b/projects/api/src/contracts/search/index.ts @@ -3,7 +3,6 @@ import { extendedQuerySchemaFactory } from '../_internal/request/extendedQuerySc import { pageQuerySchema } from '../_internal/request/pageQuerySchema.ts'; import { z } from '../_internal/z.ts'; import { recentSearchRequestSchema } from './schema/request/recentSearchRequestSchema.ts'; -import { searchEngineSchema } from './schema/request/searchEngineSchema.ts'; import { searchQuerySchema } from './schema/request/searchQuerySchema.ts'; import { searchTypeParamFactory } from './schema/request/searchTypeParamFactory.ts'; import { trendingSearchTypeParamFactory } from './schema/request/trendingSearchTypeParamFactory.ts'; @@ -15,15 +14,12 @@ import type { trendingSearchMovieResponseSchema } from './schema/response/trendi import type { trendingSearchPersonResponseSchema } from './schema/response/trendingSearchPersonResponseSchema.ts'; import { trendingSearchResponseSchema } from './schema/response/trendingSearchResponseSchema.ts'; import type { trendingSearchShowResponseSchema } from './schema/response/trendingSearchShowResponseSchema.ts'; -/** - * TODO: add support for 'episode' - */ const recent = builder.router({ add: { summary: 'Add recent search', description: `#### 🔒 OAuth Required -Add a recent search for the authenticated user. Send the search request body; a successful create returns \`201\` with no response body.`, +Add a recent search to the global search trends. This is not a user-specific recent search history. Send the search request body; a successful create returns \`201\` with no response body.`, path: '/', method: 'POST', body: recentSearchRequestSchema, @@ -34,7 +30,7 @@ Add a recent search for the authenticated user. Send the search request body; a remove: { summary: 'Remove recent search', description: `#### 🔒 OAuth Required -Remove a recent search for the authenticated user. Send the search request body; a successful delete returns \`204\` with no response body.`, +Remove a recent search from the global search trends. This is not a user-specific recent search history. Send the search request body; a successful delete returns \`204\` with no response body.`, path: '/remove', method: 'POST', body: recentSearchRequestSchema, @@ -97,11 +93,14 @@ By default, certain text fields are used to search for the \`query\`. You can op | | \`description\` | ✓ |`, path: '/:type', method: 'GET', - pathParams: searchTypeParamFactory< - ['movie', 'show', 'person', 'list'] - >(), + pathParams: searchTypeParamFactory([ + 'movie', + 'show', + 'episode', + 'person', + 'list', + ]), query: searchQuerySchema - .merge(searchEngineSchema) .merge(pageQuerySchema) .merge( extendedQuerySchemaFactory<['full,images']>(), @@ -116,11 +115,8 @@ By default, certain text fields are used to search for the \`query\`. You can op Search for exact movie or show matches using the requested search \`type\` and \`query\`. Results are paginated and can include extended media details.`, path: '/:type/exact', method: 'GET', - pathParams: searchTypeParamFactory< - ['movie', 'show'] - >(), + pathParams: searchTypeParamFactory(['movie', 'show']), query: searchQuerySchema - .merge(searchEngineSchema) .merge(pageQuerySchema) .merge( extendedQuerySchemaFactory<['full,images']>(), @@ -168,7 +164,7 @@ Returns globally trending recent searches by \`type\`. Use \`query\` to narrow t pathPrefix: '/search', }); -export { searchEngineSchema, searchQuerySchema }; +export { searchQuerySchema }; /** The search query parameters. */ export type SearchQueryParams = z.infer; export { searchResultResponseSchema }; diff --git a/projects/api/src/contracts/search/schema/request/searchEngineSchema.ts b/projects/api/src/contracts/search/schema/request/searchEngineSchema.ts deleted file mode 100644 index 2082a045..00000000 --- a/projects/api/src/contracts/search/schema/request/searchEngineSchema.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { z } from '../../../_internal/z.ts'; - -/** Zod schema for search engine. */ -export const searchEngineSchema = z.object({ - engine: z.string().nullish().openapi({ - description: 'The search engine type to use.', - }), -}); diff --git a/projects/api/src/contracts/search/schema/request/searchTypeParamFactory.ts b/projects/api/src/contracts/search/schema/request/searchTypeParamFactory.ts index dbd9b2ae..c4e5227d 100644 --- a/projects/api/src/contracts/search/schema/request/searchTypeParamFactory.ts +++ b/projects/api/src/contracts/search/schema/request/searchTypeParamFactory.ts @@ -2,7 +2,9 @@ import type { CombinationsFrom } from '../../../../types/CombinationsFrom.ts'; import { z } from '../../../_internal/z.ts'; /** Search type param factory. */ -export const searchTypeParamFactory = (): z.ZodObject<{ +export const searchTypeParamFactory = < + const T extends string[], +>(types: T): z.ZodObject<{ type: z.ZodOptional>>>; }> => z.object({ @@ -12,6 +14,6 @@ export const searchTypeParamFactory = (): z.ZodObject<{ .openapi({ description: 'Specify the type of results by sending a single value or a comma delimited string for multiple types.', - enum: ['movie', 'show', 'person'], + enum: types, }), }); diff --git a/projects/api/src/contracts/search/schema/response/searchResultResponseSchema.ts b/projects/api/src/contracts/search/schema/response/searchResultResponseSchema.ts index 3b1c18bb..c8ed4caa 100644 --- a/projects/api/src/contracts/search/schema/response/searchResultResponseSchema.ts +++ b/projects/api/src/contracts/search/schema/response/searchResultResponseSchema.ts @@ -1,13 +1,17 @@ -import { z } from '../../../_internal/z.ts'; -import { searchListResponseSchema } from './searchListResponseSchema.ts'; -import { searchMovieResponseSchema } from './searchMovieResponseSchema.ts'; -import { searchPersonResponseSchema } from './searchPersonResponseSchema.ts'; -import { searchShowResponseSchema } from './searchShowResponseSchema.ts'; +import { episodeResponseSchema } from '../../../_internal/response/episodeResponseSchema.ts'; +import { movieResponseSchema } from '../../../_internal/response/movieResponseSchema.ts'; +import { showResponseSchema } from '../../../_internal/response/showResponseSchema.ts'; +import { int64, z } from '../../../_internal/z.ts'; +import { listResponseSchema } from '../../../models/index.ts'; +import { personResponseSchema } from '../../../people/schema/response/personResponseSchema.ts'; /** Zod schema for the search result response. */ -export const searchResultResponseSchema = z.union([ - searchMovieResponseSchema, - searchShowResponseSchema, - searchPersonResponseSchema, - searchListResponseSchema, -]); +export const searchResultResponseSchema = z.object({ + score: int64(z.number().int()), + type: z.enum(['movie', 'show', 'episode', 'person', 'list']), + movie: movieResponseSchema.nullish(), + show: showResponseSchema.nullish(), + episode: episodeResponseSchema.nullish(), + person: personResponseSchema.nullish(), + list: listResponseSchema.nullish(), +}); diff --git a/projects/openapi/deno.json b/projects/openapi/deno.json index 2502479a..6d8e87ae 100644 --- a/projects/openapi/deno.json +++ b/projects/openapi/deno.json @@ -7,6 +7,7 @@ }, "imports": { "@anatine/zod-openapi": "npm:@anatine/zod-openapi@1.12.0", + "@std/assert": "jsr:@std/assert@^1.0.13", "@hono/swagger-ui": "npm:@hono/swagger-ui@^0.5.0", "@seriousme/openapi-schema-validator": "npm:@seriousme/openapi-schema-validator@^2.3.1", "@ts-rest/core": "npm:@ts-rest/core@^3.52.1", diff --git a/projects/openapi/search.test.ts b/projects/openapi/search.test.ts new file mode 100644 index 00000000..2ed104b6 --- /dev/null +++ b/projects/openapi/search.test.ts @@ -0,0 +1,100 @@ +import { assertEquals, assertFalse, assertStringIncludes } from '@std/assert'; +import type { + OperationObject, + ParameterObject, + SchemaObject, +} from 'openapi3-ts'; +import { generate } from './generate.ts'; + +const document = generate(); + +const getOperation = (path: string): OperationObject => { + const operation = document.paths[path]?.get; + if (!operation) { + throw new Error(`Missing GET operation for ${path}`); + } + return operation; +}; + +const getParameter = ( + operation: OperationObject, + name: string, +): ParameterObject => { + const parameter = operation.parameters?.find((candidate) => + !('$ref' in candidate) && candidate.name === name + ); + if (!parameter || '$ref' in parameter) { + throw new Error(`Missing ${name} parameter`); + } + return parameter; +}; + +const getSchema = (parameter: ParameterObject): SchemaObject => { + const schema = parameter.schema; + if (!schema || '$ref' in schema) { + throw new Error(`Missing inline schema for ${parameter.name}`); + } + return schema; +}; + +const getResponseItemSchema = (operation: OperationObject): SchemaObject => { + const response = operation.responses?.['200']; + if (!response || '$ref' in response) { + throw new Error('Missing inline 200 response'); + } + + const schema = response.content?.['application/json']?.schema; + if (!schema || '$ref' in schema || !schema.items || '$ref' in schema.items) { + throw new Error('Missing inline 200 response item schema'); + } + return schema.items; +}; + +Deno.test('search query docs omit the retired engine parameter', () => { + for (const path of ['/search/{type}', '/search/{type}/exact']) { + const operation = getOperation(path); + assertFalse( + operation.parameters?.some((parameter) => + !('$ref' in parameter) && parameter.name === 'engine' + ) ?? false, + ); + } +}); + +Deno.test('search type docs list the types supported by each endpoint', () => { + const queryType = getSchema( + getParameter(getOperation('/search/{type}'), 'type'), + ); + assertEquals(queryType.enum, [ + 'movie', + 'show', + 'episode', + 'person', + 'list', + ]); + + const exactType = getSchema( + getParameter(getOperation('/search/{type}/exact'), 'type'), + ); + assertEquals(exactType.enum, ['movie', 'show']); +}); + +Deno.test('search result docs expose one flat schema with episode support', () => { + const result = getResponseItemSchema(getOperation('/search/{type}')); + + assertEquals(result.oneOf, undefined); + assertEquals(result.properties?.type, { + type: 'string', + enum: ['movie', 'show', 'episode', 'person', 'list'], + }); + assertEquals(result.required, ['score', 'type']); +}); + +Deno.test('recent search docs describe global trend tracking', () => { + const addDescription = document.paths['/search/recent/']?.post?.description; + const removeDescription = document.paths['/search/recent/remove']?.post + ?.description; + + assertStringIncludes(addDescription ?? '', 'global search trends'); + assertStringIncludes(removeDescription ?? '', 'global search trends'); +});