From 467e3f4d6e1eec3b2c2f69611ced34fd3d53ad55 Mon Sep 17 00:00:00 2001 From: Jonas Natten Date: Fri, 13 Dec 2024 14:05:09 +0100 Subject: [PATCH] search-api: Use a case class for query parameters for GET `/search-api/v1/search` This allows us to have more than 22 query parameters in the future (i think) --- .../scala/no/ndla/language/Language.scala | 2 +- .../no/ndla/searchapi/ComponentRegistry.scala | 2 + .../ndla/searchapi/SearchApiProperties.scala | 2 +- .../controller/SearchController.scala | 136 ++++++------------ .../parameters/GetSearchQueryParams.scala | 116 +++++++++++++++ .../no/ndla/searchapi/TestEnvironment.scala | 2 + 6 files changed, 164 insertions(+), 96 deletions(-) create mode 100644 search-api/src/main/scala/no/ndla/searchapi/controller/parameters/GetSearchQueryParams.scala diff --git a/language/src/main/scala/no/ndla/language/Language.scala b/language/src/main/scala/no/ndla/language/Language.scala index e2f572025..c6a920d35 100644 --- a/language/src/main/scala/no/ndla/language/Language.scala +++ b/language/src/main/scala/no/ndla/language/Language.scala @@ -6,7 +6,7 @@ object Language { val DefaultLanguage = "nb" val UnknownLanguage: LanguageTag = LanguageTag("und") val NoLanguage = "" - val AllLanguages = "*" + final val AllLanguages = "*" val Nynorsk = "nynorsk" val languagePriority: Seq[String] = Seq( diff --git a/search-api/src/main/scala/no/ndla/searchapi/ComponentRegistry.scala b/search-api/src/main/scala/no/ndla/searchapi/ComponentRegistry.scala index a180dda07..a188d05f0 100644 --- a/search-api/src/main/scala/no/ndla/searchapi/ComponentRegistry.scala +++ b/search-api/src/main/scala/no/ndla/searchapi/ComponentRegistry.scala @@ -15,6 +15,7 @@ import no.ndla.network.NdlaClient import no.ndla.network.clients.{FeideApiClient, MyNDLAApiClient, RedisClient} import no.ndla.network.tapir.TapirApplication import no.ndla.search.{BaseIndexService, Elastic4sClient} +import no.ndla.searchapi.controller.parameters.GetSearchQueryParams import no.ndla.searchapi.controller.{InternController, SearchController, SwaggerDocControllerConfig} import no.ndla.searchapi.integration.* import no.ndla.searchapi.model.api.ErrorHandling @@ -47,6 +48,7 @@ class ComponentRegistry(properties: SearchApiProperties) with MyNDLAApiClient with SearchService with SearchController + with GetSearchQueryParams with FeideApiClient with RedisClient with InternController diff --git a/search-api/src/main/scala/no/ndla/searchapi/SearchApiProperties.scala b/search-api/src/main/scala/no/ndla/searchapi/SearchApiProperties.scala index ed436eaed..ca7c0038c 100644 --- a/search-api/src/main/scala/no/ndla/searchapi/SearchApiProperties.scala +++ b/search-api/src/main/scala/no/ndla/searchapi/SearchApiProperties.scala @@ -54,7 +54,7 @@ class SearchApiProperties extends BaseProps with StrictLogging { case _ => Failure(new IllegalArgumentException(s"Unknown index name: $indexName")) } - def DefaultPageSize = 10 + final val DefaultPageSize = 10 def MaxPageSize = 10000 def IndexBulkSize: Int = propOrElse("INDEX_BULK_SIZE", "100").toInt def ElasticSearchIndexMaxResultWindow = 10000 diff --git a/search-api/src/main/scala/no/ndla/searchapi/controller/SearchController.scala b/search-api/src/main/scala/no/ndla/searchapi/controller/SearchController.scala index 71c5c77a0..a1c781a8c 100644 --- a/search-api/src/main/scala/no/ndla/searchapi/controller/SearchController.scala +++ b/search-api/src/main/scala/no/ndla/searchapi/controller/SearchController.scala @@ -23,6 +23,7 @@ import no.ndla.network.tapir.TapirUtil.errorOutputsFor import no.ndla.network.tapir.auth.Permission.DRAFT_API_WRITE import no.ndla.searchapi.controller.parameters.{ DraftSearchParamsDTO, + GetSearchQueryParams, GrepSearchInputDTO, SearchParamsDTO, SubjectAggsInputDTO @@ -54,7 +55,7 @@ import sttp.tapir.server.ServerEndpoint trait SearchController { this: SearchApiClient & MultiSearchService & SearchConverterService & SearchService & MultiDraftSearchService & - FeideApiClient & Props & ErrorHandling & TapirController & GrepSearchService => + FeideApiClient & Props & ErrorHandling & TapirController & GrepSearchService & GetSearchQueryParams => val searchController: SearchController class SearchController extends TapirController { @@ -134,15 +135,6 @@ trait SearchController { .description("A comma separated list of codes from GREP API the resources should be filtered by.") private val traits = listQuery[String]("traits") .description("A comma separated list of traits the resources should be filtered by.") - private val scrollId = query[Option[String]]("search-context") - .description( - s"""A unique string obtained from a search you want to keep scrolling in. To obtain one from a search, provide one of the following values: ${InitialScrollContextKeywords - .mkString("[", ",", "]")}. - |When scrolling, the parameters from the initial search is used, except in the case of '${this.language.name}' and '${this.fallback.name}'. - |This value may change between scrolls. Always use the one in the latest scroll result (The context, if unused, dies after $ElasticSearchScrollKeepAlive). - |If you are not paginating past $ElasticSearchIndexMaxResultWindow hits, you can ignore this and use '${this.pageNo.name}' and '${this.pageSize.name}' instead. - |""".stripMargin - ) private val aggregatePaths = listQuery[String]("aggregate-paths") .description("List of index-paths that should be term-aggregated and returned in result.") private val embedResource = @@ -363,91 +355,47 @@ trait SearchController { .errorOut(errorOutputsFor(400, 401, 403)) .out(jsonBody[MultiSearchResultDTO]) .out(EndpointOutput.derived[DynamicHeaders]) - .in(pageNo) - .in(pageSize) - .in(articleTypes) - .in(contextTypes) - .in(language) - .in(learningResourceIds) - .in(resourceTypes) - .in(license) - .in(queryParam) - .in(sort) - .in(fallback) - .in(subjects) - .in(languageFilter) - .in(relevanceFilter) - .in(scrollId) - .in(grepCodes) - .in(traits) - .in(aggregatePaths) - .in(embedResource) - .in(embedId) - .in(filterInactive) + .in(GetSearchQueryParams.input) .in(feideHeader) - .serverLogicPure { - case ( - page, - pageSize, - articleTypes, - contextTypes, - language, - learningResourceIds, - resourceTypes, - license, - query, - sortStr, - fallback, - subjects, - languageFilter, - relevanceFilter, - scrollId, - grepCodes, - traits, - aggregatePaths, - embedResource, - embedId, - filterInactive, - feideToken - ) => - scrollWithOr(scrollId, language, multiSearchService) { - val sort = sortStr.flatMap(Sort.valueOf) - val shouldScroll = scrollId.exists(InitialScrollContextKeywords.contains) - getAvailability(feideToken).flatMap(availability => { - val settings = SearchSettings( - query = query, - fallback = fallback, - language = language, - license = license, - page = page, - pageSize = pageSize, - sort = sort.getOrElse(Sort.ByRelevanceDesc), - withIdIn = learningResourceIds.values, - subjects = subjects.values, - resourceTypes = resourceTypes.values, - learningResourceTypes = contextTypes.values.flatMap(LearningResourceType.valueOf), - supportedLanguages = languageFilter.values, - relevanceIds = relevanceFilter.values, - grepCodes = grepCodes.values, - traits = traits.values.flatMap(SearchTrait.valueOf), - shouldScroll = shouldScroll, - filterByNoResourceType = false, - aggregatePaths = aggregatePaths.values, - embedResource = embedResource.values, - embedId = embedId, - availability = availability, - articleTypes = articleTypes.values, - filterInactive = filterInactive - ) - multiSearchService.matchingQuery(settings) match { - case Success(searchResult) => - val result = searchConverterService.toApiMultiSearchResult(searchResult) - val headers = DynamicHeaders.fromMaybeValue("search-context", searchResult.scrollId) - Success((result, headers)) - case Failure(ex) => Failure(ex) - } - }) - } + .serverLogicPure { case (q, feideToken) => + scrollWithOr(q.scrollId, q.language, multiSearchService) { + val sort = q.sort.flatMap(Sort.valueOf) + val shouldScroll = q.scrollId.exists(InitialScrollContextKeywords.contains) + getAvailability(feideToken).flatMap(availability => { + val settings = SearchSettings( + query = q.queryParam, + fallback = q.fallback, + language = q.language, + license = q.license, + page = q.page, + pageSize = q.pageSize, + sort = sort.getOrElse(Sort.ByRelevanceDesc), + withIdIn = q.learningResourceIds.values, + subjects = q.subjects.values, + resourceTypes = q.resourceTypes.values, + learningResourceTypes = q.contextTypes.values.flatMap(LearningResourceType.valueOf), + supportedLanguages = q.languageFilter.values, + relevanceIds = q.relevanceFilter.values, + grepCodes = q.grepCodes.values, + shouldScroll = shouldScroll, + filterByNoResourceType = false, + aggregatePaths = q.aggregatePaths.values, + embedResource = q.embedResource.values, + embedId = q.embedId, + availability = availability, + articleTypes = q.articleTypes.values, + filterInactive = q.filterInactive, + traits = q.traits.values.flatMap(SearchTrait.valueOf) + ) + multiSearchService.matchingQuery(settings) match { + case Success(searchResult) => + val result = searchConverterService.toApiMultiSearchResult(searchResult) + val headers = DynamicHeaders.fromMaybeValue("search-context", searchResult.scrollId) + Success((result, headers)) + case Failure(ex) => Failure(ex) + } + }) + } } diff --git a/search-api/src/main/scala/no/ndla/searchapi/controller/parameters/GetSearchQueryParams.scala b/search-api/src/main/scala/no/ndla/searchapi/controller/parameters/GetSearchQueryParams.scala new file mode 100644 index 000000000..7fd039685 --- /dev/null +++ b/search-api/src/main/scala/no/ndla/searchapi/controller/parameters/GetSearchQueryParams.scala @@ -0,0 +1,116 @@ +/* + * Part of NDLA backend.search-api.main + * Copyright (C) 2024 NDLA + * + * See LICENSE + * + */ + +package no.ndla.searchapi.controller.parameters + +import no.ndla.common.model.api.CommaSeparatedList.CommaSeparatedList +import no.ndla.language.Language +import no.ndla.network.tapir.NonEmptyString +import no.ndla.searchapi.Props +import sttp.tapir.* +import sttp.tapir.EndpointIO.annotations.query +import sttp.tapir.Schema.annotations.{default, description, customise} +import sttp.tapir.ValidationResult.{Invalid, Valid} + +trait GetSearchQueryParams { + this: Props => + + case class GetSearchQueryParams( + @query("page") + @description("The page number of the search hits to display.") + @default(1) + page: Int, + @query("page-size") + @description("The number of search hits to display for each page.") + @default(props.DefaultPageSize) + pageSize: Int, + @query("article-types") + @description("A comma separated list of article-types the search should be filtered by.") + articleTypes: CommaSeparatedList[String], + @query("context-types") + @description("A comma separated list of types the learning resources should be filtered by.") + contextTypes: CommaSeparatedList[String], + @query("language") + @description("The ISO 639-1 language code describing language.") + @default(Language.AllLanguages) + language: String, + @query("ids") + @description( + "Return only learning resources that have one of the provided ids. To provide multiple ids, separate by comma (,)." + ) + learningResourceIds: CommaSeparatedList[Long], + @query("resource-types") + @description( + "Return only learning resources with specific taxonomy type(s), e.g. 'urn:resourcetype:learningpath'. To provide multiple types, separate by comma (,)." + ) + resourceTypes: CommaSeparatedList[String], + @query("license") + @description("Return only results with provided license.") + license: Option[String], + @query("query") + @description("Return only results with content matching the specified query.") + @default(None) + queryParam: Option[NonEmptyString], + @query("sort") + @description("Sort the search results by the specified field.") + sort: Option[String], + @query("fallback") + @default(false) + @description("Fallback to existing language if language is specified.") + fallback: Boolean, + @query("subjects") + @description("A comma separated list of subjects the learning resources should be filtered by.") + subjects: CommaSeparatedList[String], + @query("language-filter") + @description("A comma separated list of ISO 639-1 language codes that the learning resource can be available in.") + languageFilter: CommaSeparatedList[String], + @query("relevance") + @description( + "A comma separated list of relevances the learning resources should be filtered by. If subjects are specified the learning resource must have specified relevances in relation to a specified subject. If levels are specified the learning resource must have specified relevances in relation to a specified level." + ) + relevanceFilter: CommaSeparatedList[String], + @query("search-context") + @description("A unique string obtained from a search you want to keep scrolling in.") + scrollId: Option[String], + @query("grep-codes") + @description("A comma separated list of codes from GREP API the resources should be filtered by.") + grepCodes: CommaSeparatedList[String], + @query("aggregate-paths") + @description("List of index-paths that should be term-aggregated and returned in result.") + aggregatePaths: CommaSeparatedList[String], + @query("embed-resource") + @description( + "Return only results with embed data-resource the specified resource. Can specify multiple with a comma separated list to filter for one of the embed types." + ) + embedResource: CommaSeparatedList[String], + @query("embed-id") + @description("Return only results with embed data-resource_id, data-videoid or data-url with the specified id.") + embedId: Option[String], + @query("filter-inactive") + @description("Filter out inactive taxonomy contexts.") + @default(false) + filterInactive: Boolean, + @query("traits") + @description("A comma separated list of traits the resources should be filtered by.") + traits: CommaSeparatedList[String] + ) + + object GetSearchQueryParams { + implicit val schema: Schema[GetSearchQueryParams] = Schema.derived[GetSearchQueryParams] + implicit val schemaOpt: Schema[Option[GetSearchQueryParams]] = schema.asOption + def input = EndpointInput + .derived[GetSearchQueryParams] + .validate { + Validator.custom { + case q if q.page < 1 => Invalid("page must be greater than 0") + case q if q.pageSize < 1 || q.pageSize > 100 => Invalid("page-size must be between 1 and 100") + case _ => Valid + } + } + } +} diff --git a/search-api/src/test/scala/no/ndla/searchapi/TestEnvironment.scala b/search-api/src/test/scala/no/ndla/searchapi/TestEnvironment.scala index 70e8f265f..12e81b9e0 100644 --- a/search-api/src/test/scala/no/ndla/searchapi/TestEnvironment.scala +++ b/search-api/src/test/scala/no/ndla/searchapi/TestEnvironment.scala @@ -14,6 +14,7 @@ import no.ndla.network.NdlaClient import no.ndla.network.clients.{FeideApiClient, MyNDLAApiClient, RedisClient} import no.ndla.network.tapir.TapirApplication import no.ndla.search.{BaseIndexService, Elastic4sClient} +import no.ndla.searchapi.controller.parameters.GetSearchQueryParams import no.ndla.searchapi.controller.{InternController, SearchController} import no.ndla.searchapi.integration.* import no.ndla.searchapi.model.api.ErrorHandling @@ -46,6 +47,7 @@ trait TestEnvironment with MyNDLAApiClient with SearchService with SearchController + with GetSearchQueryParams with GrepSearchService with LearningPathIndexService with InternController