Skip to content
Open
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
1 change: 1 addition & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 11 additions & 15 deletions projects/api/src/contracts/search/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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']>(),
Expand All @@ -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']>(),
Expand Down Expand Up @@ -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<typeof searchQuerySchema>;
export { searchResultResponseSchema };
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import type { CombinationsFrom } from '../../../../types/CombinationsFrom.ts';
import { z } from '../../../_internal/z.ts';

/** Search type param factory. */
export const searchTypeParamFactory = <T extends string[]>(): z.ZodObject<{
export const searchTypeParamFactory = <
const T extends string[],
>(types: T): z.ZodObject<{
type: z.ZodOptional<z.ZodNullable<z.ZodType<CombinationsFrom<T>>>>;
}> =>
z.object({
Expand All @@ -12,6 +14,6 @@ export const searchTypeParamFactory = <T extends string[]>(): 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,
}),
});
Original file line number Diff line number Diff line change
@@ -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(),
});
1 change: 1 addition & 0 deletions projects/openapi/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 100 additions & 0 deletions projects/openapi/search.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
Loading