From deb09ce2fb76f91c8778763d200e0a56303bd313 Mon Sep 17 00:00:00 2001 From: Gunnar Velle Date: Mon, 23 Feb 2026 11:59:38 +0100 Subject: [PATCH 1/6] Add contentType filter for images --- .../no/ndla/imageapi/controller/BaseImageController.scala | 3 +++ .../no/ndla/imageapi/controller/ImageControllerV2.scala | 8 ++++++++ .../no/ndla/imageapi/controller/ImageControllerV3.scala | 8 ++++++++ .../no/ndla/imageapi/model/api/SearchParamsDTO.scala | 2 ++ .../no/ndla/imageapi/model/domain/SearchSettings.scala | 1 + .../ndla/imageapi/service/search/ImageIndexService.scala | 1 + .../ndla/imageapi/service/search/ImageSearchService.scala | 6 ++++++ image-api/src/test/scala/no/ndla/imageapi/TestData.scala | 1 + 8 files changed, 30 insertions(+) diff --git a/image-api/src/main/scala/no/ndla/imageapi/controller/BaseImageController.scala b/image-api/src/main/scala/no/ndla/imageapi/controller/BaseImageController.scala index f178ad2c82..dc86123f9f 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/controller/BaseImageController.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/controller/BaseImageController.scala @@ -102,6 +102,9 @@ trait BaseImageController(using props: Props) { val heightTo: EndpointInput.Query[Option[Int]] = query[Option[Int]]("height-to").description("Filter images with height less than or equal to this value.") + val contentType: EndpointInput.Query[Option[String]] = + query[Option[String]]("content-type").description("Filter images by content type (e.g., 'image/jpeg', 'image/png').") + val maxImageFileSizeBytes: Int = props.MaxImageFileSizeBytes def doWithStream[T](filePart: Part[File])(f: UploadedFile => Try[T]): Try[T] = { diff --git a/image-api/src/main/scala/no/ndla/imageapi/controller/ImageControllerV2.scala b/image-api/src/main/scala/no/ndla/imageapi/controller/ImageControllerV2.scala index 100d47f64c..5f9ef66f8a 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/controller/ImageControllerV2.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/controller/ImageControllerV2.scala @@ -100,6 +100,7 @@ class ImageControllerV2(using widthTo: Option[Int], heightFrom: Option[Int], heightTo: Option[Int], + contentType: Option[String], ) = { val settings = query match { case Some(searchString) => SearchSettings( @@ -120,6 +121,7 @@ class ImageControllerV2(using widthTo = widthTo, heightFrom = heightFrom, heightTo = heightTo, + contentType = contentType, ) case None => SearchSettings( query = None, @@ -139,6 +141,7 @@ class ImageControllerV2(using widthTo = widthTo, heightFrom = heightFrom, heightTo = heightTo, + contentType = contentType, ) } @@ -171,6 +174,7 @@ class ImageControllerV2(using .in(widthTo) .in(heightFrom) .in(heightTo) + .in(contentType) .errorOut(errorOutputsFor(400)) .out(jsonBody[SearchResultDTO]) .out(EndpointOutput.derived[DynamicHeaders]) @@ -194,6 +198,7 @@ class ImageControllerV2(using widthTo, heightFrom, heightTo, + contentType, ) => scrollSearchOr(scrollId, language, user) { val sort = Sort.valueOf(sortStr) val shouldScroll = scrollId.exists(props.InitialScrollContextKeywords.contains) @@ -217,6 +222,7 @@ class ImageControllerV2(using widthTo, heightFrom, heightTo, + contentType, ) }.handleErrorsOrOk } @@ -250,6 +256,7 @@ class ImageControllerV2(using val widthTo = searchParams.widthTo val heightFrom = searchParams.heightFrom val heightTo = searchParams.heightTo + val contentType = searchParams.contentType search( minimumSize, @@ -269,6 +276,7 @@ class ImageControllerV2(using widthTo, heightFrom, heightTo, + contentType, ) }.handleErrorsOrOk }) diff --git a/image-api/src/main/scala/no/ndla/imageapi/controller/ImageControllerV3.scala b/image-api/src/main/scala/no/ndla/imageapi/controller/ImageControllerV3.scala index 9347cec139..9a86b31bf9 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/controller/ImageControllerV3.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/controller/ImageControllerV3.scala @@ -88,6 +88,7 @@ class ImageControllerV3(using widthTo: Option[Int], heightFrom: Option[Int], heightTo: Option[Int], + contentType: Option[String], ) = { val settings = query match { case Some(searchString) => SearchSettings( @@ -108,6 +109,7 @@ class ImageControllerV3(using widthTo = widthTo, heightFrom = heightFrom, heightTo = heightTo, + contentType = contentType, ) case None => SearchSettings( query = None, @@ -127,6 +129,7 @@ class ImageControllerV3(using widthTo = widthTo, heightFrom = heightFrom, heightTo = heightTo, + contentType = contentType, ) } for { @@ -158,6 +161,7 @@ class ImageControllerV3(using .in(widthTo) .in(heightFrom) .in(heightTo) + .in(contentType) .errorOut(errorOutputsFor(400)) .out(jsonBody[SearchResultV3DTO]) .out(EndpointOutput.derived[DynamicHeaders]) @@ -183,6 +187,7 @@ class ImageControllerV3(using widthTo, heightFrom, heightTo, + contentType, ) => scrollSearchOr(scrollId, language.code, user) { val sort = Sort.valueOf(sortStr) val shouldScroll = scrollId.exists(props.InitialScrollContextKeywords.contains) @@ -208,6 +213,7 @@ class ImageControllerV3(using widthTo, heightFrom, heightTo, + contentType, ) }.handleErrorsOrOk } @@ -248,6 +254,7 @@ class ImageControllerV3(using val widthTo = searchParams.widthTo val heightFrom = searchParams.heightFrom val heightTo = searchParams.heightTo + val contentType = searchParams.contentType searchV3( minimumSize, @@ -268,6 +275,7 @@ class ImageControllerV3(using widthTo, heightFrom, heightTo, + contentType, ) }.handleErrorsOrOk } diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/api/SearchParamsDTO.scala b/image-api/src/main/scala/no/ndla/imageapi/model/api/SearchParamsDTO.scala index 9d796c277a..15eb7ba0f9 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/model/api/SearchParamsDTO.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/model/api/SearchParamsDTO.scala @@ -55,6 +55,8 @@ case class SearchParamsDTO( heightFrom: Option[Int], @description("Filter images with height less than or equal to this value.") heightTo: Option[Int], + @description("Filter images by content type (e.g., 'image/jpeg', 'image/png').") + contentType: Option[String], ) object SearchParamsDTO { diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/domain/SearchSettings.scala b/image-api/src/main/scala/no/ndla/imageapi/model/domain/SearchSettings.scala index db65368b4d..5f65754c6e 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/model/domain/SearchSettings.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/model/domain/SearchSettings.scala @@ -26,4 +26,5 @@ case class SearchSettings( widthTo: Option[Int], heightFrom: Option[Int], heightTo: Option[Int], + contentType: Option[String], ) diff --git a/image-api/src/main/scala/no/ndla/imageapi/service/search/ImageIndexService.scala b/image-api/src/main/scala/no/ndla/imageapi/service/search/ImageIndexService.scala index 382a5632b3..fc17233385 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/service/search/ImageIndexService.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/service/search/ImageIndexService.scala @@ -71,6 +71,7 @@ class ImageIndexService(using nestedField("imageFiles").fields( intField("imageSize"), textField("previewUrl"), + keywordField("contentType"), ObjectField("dimensions", properties = Seq(intField("width"), intField("height"))), ), ) diff --git a/image-api/src/main/scala/no/ndla/imageapi/service/search/ImageSearchService.scala b/image-api/src/main/scala/no/ndla/imageapi/service/search/ImageSearchService.scala index 9958e34ca2..f8accc03d3 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/service/search/ImageSearchService.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/service/search/ImageSearchService.scala @@ -177,6 +177,11 @@ class ImageSearchService(using case (None, None) => None } + val contentTypeFilter = settings.contentType match { + case Some(ct) => Some(nestedQuery("imageFiles", termQuery("imageFiles.contentType", ct))) + case None => None + } + val filters = List( languageFilter, licenseFilter, @@ -187,6 +192,7 @@ class ImageSearchService(using userFilter, widthFilter, heightFilter, + contentTypeFilter, ) val filteredSearch = queryBuilder.filter(filters.flatten) diff --git a/image-api/src/test/scala/no/ndla/imageapi/TestData.scala b/image-api/src/test/scala/no/ndla/imageapi/TestData.scala index 1fa21f3b1d..37c29ee5da 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/TestData.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/TestData.scala @@ -396,5 +396,6 @@ class TestData(using props: Props) { widthTo = None, heightFrom = None, heightTo = None, + contentType = None, ) } From ea8716523c54e07242878ce8ad75bcf7afa31982 Mon Sep 17 00:00:00 2001 From: Gunnar Velle Date: Mon, 23 Feb 2026 12:39:42 +0100 Subject: [PATCH 2/6] Add tests --- .../search/ImageSearchServiceTest.scala | 225 +++++++++++++++--- 1 file changed, 188 insertions(+), 37 deletions(-) diff --git a/image-api/src/test/scala/no/ndla/imageapi/service/search/ImageSearchServiceTest.scala b/image-api/src/test/scala/no/ndla/imageapi/service/search/ImageSearchServiceTest.scala index 5c64f6c99b..9cf7fc162e 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/service/search/ImageSearchServiceTest.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/service/search/ImageSearchServiceTest.scala @@ -41,16 +41,56 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit } override implicit lazy val imageSearchService: ImageSearchService = new ImageSearchService - val largeImage: ImageFileData = - ImageFileData("large-full-url", 10000, "jpg", Some(ImageDimensions(width = 1920, height = 1080)), Seq.empty, "und") - val smallImage: ImageFileData = - ImageFileData("small-full-url", 100, "jpg", Some(ImageDimensions(width = 640, height = 480)), Seq.empty, "und") - val podcastImage: ImageFileData = - ImageFileData("podcast-full-url", 100, "jpg", Some(ImageDimensions(width = 1400, height = 1400)), Seq.empty, "und") - val wideImage: ImageFileData = - ImageFileData("wide-full-url", 5000, "jpg", Some(ImageDimensions(width = 3840, height = 2160)), Seq.empty, "und") - val tallImage: ImageFileData = - ImageFileData("tall-full-url", 3000, "jpg", Some(ImageDimensions(width = 1080, height = 1920)), Seq.empty, "und") + val largeImage: ImageFileData = ImageFileData( + "large-full-url", + 10000, + "image/jpeg", + Some(ImageDimensions(width = 1920, height = 1080)), + Seq.empty, + "und", + ) + val smallImage: ImageFileData = ImageFileData( + "small-full-url", + 100, + "image/jpeg", + Some(ImageDimensions(width = 640, height = 480)), + Seq.empty, + "und", + ) + val podcastImage: ImageFileData = ImageFileData( + "podcast-full-url", + 100, + "image/jpeg", + Some(ImageDimensions(width = 1400, height = 1400)), + Seq.empty, + "und", + ) + val wideImage: ImageFileData = ImageFileData( + "wide-full-url", + 5000, + "image/jpeg", + Some(ImageDimensions(width = 3840, height = 2160)), + Seq.empty, + "und", + ) + val tallImage: ImageFileData = ImageFileData( + "tall-full-url", + 3000, + "image/jpeg", + Some(ImageDimensions(width = 1080, height = 1920)), + Seq.empty, + "und", + ) + val pngImage: ImageFileData = + ImageFileData("png-full-url", 2000, "image/png", Some(ImageDimensions(width = 800, height = 600)), Seq.empty, "und") + val svgImage: ImageFileData = ImageFileData( + "svg-full-url", + 500, + "image/svg+xml", + Some(ImageDimensions(width = 512, height = 512)), + Seq.empty, + "und", + ) val byNcSa: Copyright = Copyright( CC_BY_NC_SA.toString, @@ -230,6 +270,40 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit inactive = false, ) + val image9 = new ImageMetaInformation( + id = Some(9), + titles = List(ImageTitle("PNG logo image", "en")), + alttexts = List(ImageAltText("A transparent PNG logo", "en")), + images = Seq(pngImage), + copyright = byNcSa, + tags = List(Tag(List("logo"), "en")), + captions = List(), + updatedBy = "ndla124", + updated = updated, + created = updated, + createdBy = "ndla124", + modelReleased = ModelReleasedStatus.YES, + editorNotes = Seq.empty, + inactive = false, + ) + + val image10 = new ImageMetaInformation( + id = Some(10), + titles = List(ImageTitle("SVG vector graphic", "en")), + alttexts = List(ImageAltText("A scalable vector graphic", "en")), + images = Seq(svgImage), + copyright = publicDomain, + tags = List(Tag(List("vector"), "en")), + captions = List(), + updatedBy = "ndla124", + updated = updated, + created = updated, + createdBy = "ndla124", + modelReleased = ModelReleasedStatus.YES, + editorNotes = Seq.empty, + inactive = false, + ) + override def beforeAll(): Unit = { super.beforeAll() if (elasticSearchContainer.isSuccess) { @@ -244,6 +318,8 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit imageIndexService.indexDocument(image6).get imageIndexService.indexDocument(image7).get imageIndexService.indexDocument(image8).get + imageIndexService.indexDocument(image9).get + imageIndexService.indexDocument(image10).get val servletRequest = mock[NdlaHttpRequest] when(servletRequest.getHeader(any[String])).thenReturn(Some("http")) @@ -251,7 +327,7 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit when(servletRequest.servletPath).thenReturn("/image-api/v2/images/") ApplicationUrl.set(servletRequest) - blockUntil(() => imageSearchService.countDocuments() == 8) + blockUntil(() => imageSearchService.countDocuments() == 10) } } @@ -280,28 +356,29 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit test("That all returns all documents ordered by id ascending") { val searchResult = imageSearchService.matchingQuery(searchSettings.copy(), None).get - searchResult.totalCount should be(8) - searchResult.results.size should be(8) + searchResult.totalCount should be(10) + searchResult.results.size should be(10) searchResult.page.get should be(1) searchResult.results.head.id should be("1") - searchResult.results.last.id should be("8") + searchResult.results.last.id should be("10") } test("That all filtering on minimumsize only returns images larger than minimumsize") { val Success(searchResult) = imageSearchService.matchingQuery(searchSettings.copy(minimumSize = Some(500)), None): @unchecked - searchResult.totalCount should be(4) - searchResult.results.size should be(4) + searchResult.totalCount should be(6) + searchResult.results.size should be(6) searchResult.results.head.id should be("1") - searchResult.results.last.id should be("8") + searchResult.results.last.id should be("10") } test("That all filtering on license only returns images with given license") { val Success(searchResult) = imageSearchService.matchingQuery(searchSettings.copy(license = Some(PublicDomain.toString)), None): @unchecked - searchResult.totalCount should be(1) - searchResult.results.size should be(1) + searchResult.totalCount should be(2) + searchResult.results.size should be(2) searchResult.results.head.id should be("2") + searchResult.results.last.id should be("10") } test("That paging returns only hits on current page and not more than page-size") { @@ -309,14 +386,14 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit imageSearchService.matchingQuery(searchSettings.copy(page = Some(1), pageSize = Some(2)), None): @unchecked val Success(searchResultPage2) = imageSearchService.matchingQuery(searchSettings.copy(page = Some(2), pageSize = Some(2)), None): @unchecked - searchResultPage1.totalCount should be(8) + searchResultPage1.totalCount should be(10) searchResultPage1.page.get should be(1) searchResultPage1.pageSize should be(2) searchResultPage1.results.size should be(2) searchResultPage1.results.head.id should be("1") searchResultPage1.results.last.id should be("2") - searchResultPage2.totalCount should be(8) + searchResultPage2.totalCount should be(10) searchResultPage2.page.get should be(2) searchResultPage2.pageSize should be(2) searchResultPage2.results.size should be(2) @@ -329,9 +406,10 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit searchSettings.copy(minimumSize = Some(500), license = Some(PublicDomain.toString)), None, ): @unchecked - searchResult.totalCount should be(1) - searchResult.results.size should be(1) + searchResult.totalCount should be(2) + searchResult.results.size should be(2) searchResult.results.head.id should be("2") + searchResult.results.last.id should be("10") } test("That search matches title and alttext ordered by relevance") { @@ -364,9 +442,10 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit test("That search on author matches corresponding author on image") { val Success(searchResult) = imageSearchService.matchingQuery(searchSettings.copy(query = Some("Bruce Wayne")), None): @unchecked - searchResult.totalCount should be(1) - searchResult.results.size should be(1) + searchResult.totalCount should be(2) + searchResult.results.size should be(2) searchResult.results.head.id should be("2") + searchResult.results.last.id should be("10") } test("That search matches tags") { @@ -488,7 +567,7 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit test("That scrolling works as expected") { val pageSize = 2 - val expectedIds = List("1", "2", "3", "4", "5", "6", "7", "8").sliding(pageSize, pageSize).toList + val expectedIds = List("1", "2", "3", "4", "5", "6", "7", "8", "9", "10").sliding(pageSize, pageSize).toList val Success(initialSearch) = imageSearchService.matchingQuery( searchSettings.copy(pageSize = Some(pageSize), shouldScroll = true), @@ -507,7 +586,7 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit test("That scrolling v3 works as expected") { val pageSize = 2 - val expectedIds = List[Long](1, 2, 3, 4, 5, 6, 7, 8).sliding(pageSize, pageSize).toList + val expectedIds = List[Long](1, 2, 3, 4, 5, 6, 7, 8, 9, 10).sliding(pageSize, pageSize).toList val Success(initialSearch) = imageSearchService.matchingQueryV3( searchSettings.copy(pageSize = Some(pageSize), shouldScroll = true), @@ -564,12 +643,12 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit val Success(searchResult3) = imageSearchService.matchingQuery(searchSettings.copy(language = "*", modelReleased = Seq(YES)), None): @unchecked - searchResult3.results.map(_.id) should be(Seq("3", "4", "5", "6", "7", "8")) + searchResult3.results.map(_.id) should be(Seq("3", "4", "5", "6", "7", "8", "9", "10")) val Success(searchResult4) = imageSearchService.matchingQuery(searchSettings.copy(language = "*", modelReleased = Seq.empty), None): @unchecked - searchResult4.results.map(_.id) should be(Seq("1", "2", "3", "4", "5", "6", "7", "8")) + searchResult4.results.map(_.id) should be(Seq("1", "2", "3", "4", "5", "6", "7", "8", "9", "10")) val Success(searchResult5) = imageSearchService.matchingQuery( searchSettings.copy(language = "*", modelReleased = Seq(NO, NOT_APPLICABLE)), @@ -623,8 +702,8 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit test("That not including inactive option returns all images") { val Success(searchResult) = imageSearchService.matchingQuery(searchSettings, None): @unchecked - searchResult.totalCount should be(8) - searchResult.results.last.id should be("8") + searchResult.totalCount should be(10) + searchResult.results.last.id should be("10") } test("That including inactive images work") { @@ -639,8 +718,8 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit val Success(searchResult) = imageSearchService.matchingQuery(searchSettings.copy(inactive = Some(false)), None): @unchecked - searchResult.totalCount should be(7) - searchResult.results.last.id should be("8") + searchResult.totalCount should be(9) + searchResult.results.last.id should be("10") } test("That filtering on width-from returns only images with width >= specified value") { @@ -655,8 +734,8 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit val Success(searchResult) = imageSearchService.matchingQuery(searchSettings.copy(widthTo = Some(1080)), None): @unchecked - searchResult.totalCount should be(4) - searchResult.results.map(_.id) should be(Seq("3", "4", "6", "8")) + searchResult.totalCount should be(6) + searchResult.results.map(_.id) should be(Seq("3", "4", "6", "8", "9", "10")) } test("That filtering on width range (from-to) returns only images within range") { @@ -681,8 +760,8 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit val Success(searchResult) = imageSearchService.matchingQuery(searchSettings.copy(heightTo = Some(1080)), None): @unchecked - searchResult.totalCount should be(5) - searchResult.results.map(_.id) should be(Seq("1", "2", "3", "4", "6")) + searchResult.totalCount should be(7) + searchResult.results.map(_.id) should be(Seq("1", "2", "3", "4", "6", "9", "10")) } test("That filtering on height range (from-to) returns only images within range") { @@ -733,4 +812,76 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit searchResult.totalCount should be(0) searchResult.results should be(Seq.empty) } + + test("That filtering on contentType returns only images with specified content type") { + val Success(searchResult) = + imageSearchService.matchingQuery(searchSettings.copy(contentType = Some("image/jpeg")), None): @unchecked + + searchResult.totalCount should be(8) + searchResult.results.map(_.id) should be(Seq("1", "2", "3", "4", "5", "6", "7", "8")) + } + + test("That filtering on contentType for PNG returns only PNG images") { + val Success(searchResult) = + imageSearchService.matchingQuery(searchSettings.copy(contentType = Some("image/png")), None): @unchecked + + searchResult.totalCount should be(1) + searchResult.results.map(_.id) should be(Seq("9")) + } + + test("That filtering on contentType for SVG returns only SVG images") { + val Success(searchResult) = + imageSearchService.matchingQuery(searchSettings.copy(contentType = Some("image/svg+xml")), None): @unchecked + + searchResult.totalCount should be(1) + searchResult.results.map(_.id) should be(Seq("10")) + } + + test("That filtering on non-existent contentType returns empty result") { + val Success(searchResult) = + imageSearchService.matchingQuery(searchSettings.copy(contentType = Some("image/gif")), None): @unchecked + + searchResult.totalCount should be(0) + searchResult.results should be(Seq.empty) + } + + test("That contentType filter can be combined with other filters") { + // PNG images with YES model released status + val Success(searchResult1) = imageSearchService.matchingQuery( + searchSettings.copy(contentType = Some("image/png"), modelReleased = Seq(ModelReleasedStatus.YES)), + None, + ): @unchecked + + searchResult1.totalCount should be(1) + searchResult1.results.map(_.id) should be(Seq("9")) + + // JPEG images larger than 500 bytes + val Success(searchResult2) = imageSearchService.matchingQuery( + searchSettings.copy(contentType = Some("image/jpeg"), minimumSize = Some(500)), + None, + ): @unchecked + + searchResult2.totalCount should be(4) + searchResult2.results.map(_.id) should be(Seq("1", "2", "7", "8")) + + // SVG images with public domain license + val Success(searchResult3) = imageSearchService.matchingQuery( + searchSettings.copy(contentType = Some("image/svg+xml"), license = Some(PublicDomain.toString)), + None, + ): @unchecked + + searchResult3.totalCount should be(1) + searchResult3.results.map(_.id) should be(Seq("10")) + } + + test("That contentType filter works with search queries") { + // Search for "logo" with PNG content type + val Success(searchResult) = imageSearchService.matchingQuery( + searchSettings.copy(query = Some("logo"), contentType = Some("image/png")), + None, + ): @unchecked + + searchResult.totalCount should be(1) + searchResult.results.map(_.id) should be(Seq("9")) + } } From 41b59230d77391bc9ca5349830666d3db0173cab Mon Sep 17 00:00:00 2001 From: Gunnar Velle Date: Mon, 23 Feb 2026 12:56:26 +0100 Subject: [PATCH 3/6] fix formatting --- .../no/ndla/imageapi/controller/BaseImageController.scala | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/image-api/src/main/scala/no/ndla/imageapi/controller/BaseImageController.scala b/image-api/src/main/scala/no/ndla/imageapi/controller/BaseImageController.scala index dc86123f9f..b4ba16a1c1 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/controller/BaseImageController.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/controller/BaseImageController.scala @@ -102,8 +102,9 @@ trait BaseImageController(using props: Props) { val heightTo: EndpointInput.Query[Option[Int]] = query[Option[Int]]("height-to").description("Filter images with height less than or equal to this value.") - val contentType: EndpointInput.Query[Option[String]] = - query[Option[String]]("content-type").description("Filter images by content type (e.g., 'image/jpeg', 'image/png').") + val contentType: EndpointInput.Query[Option[String]] = query[Option[String]]("content-type").description( + "Filter images by content type (e.g., 'image/jpeg', 'image/png')." + ) val maxImageFileSizeBytes: Int = props.MaxImageFileSizeBytes From c6f8f4078c927318efec0e14992788cdcaec6790 Mon Sep 17 00:00:00 2001 From: Gunnar Velle Date: Mon, 23 Feb 2026 14:51:34 +0100 Subject: [PATCH 4/6] Simplify image-validation with imagecontenttype --- .../no/ndla/imageapi/ImageApiProperties.scala | 15 +----- .../model/domain/ImageContentType.scala | 33 +++++++++++++ .../imageapi/service/ValidationService.scala | 46 +++++++++++-------- .../service/ValidationServiceTest.scala | 5 +- 4 files changed, 66 insertions(+), 33 deletions(-) create mode 100644 image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageContentType.scala diff --git a/image-api/src/main/scala/no/ndla/imageapi/ImageApiProperties.scala b/image-api/src/main/scala/no/ndla/imageapi/ImageApiProperties.scala index ec39cf4b11..85570614d5 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/ImageApiProperties.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/ImageApiProperties.scala @@ -11,6 +11,7 @@ package no.ndla.imageapi import com.typesafe.scalalogging.StrictLogging import no.ndla.common.configuration.BaseProps import no.ndla.database.DatabaseProps +import no.ndla.imageapi.model.domain.ImageContentType import no.ndla.network.{AuthUser, Domains} import scala.util.Properties.* @@ -31,19 +32,7 @@ class ImageApiProperties extends BaseProps with DatabaseProps with StrictLogging val ImageControllerV3Path: String = s"$ImageApiBasePath/v3/images" val RawControllerPath: String = s"$ImageApiBasePath/raw" - val ValidFileExtensions: Seq[String] = Seq(".jpg", ".png", ".jpeg", ".bmp", ".gif", ".svg", ".jfif") - - val ValidMimeTypes: Seq[String] = Seq( - "image/bmp", - "image/gif", - "image/jpeg", - "image/x-citrix-jpeg", - "image/pjpeg", - "image/png", - "image/x-citrix-png", - "image/x-png", - "image/svg+xml", - ) + val ValidMimeTypes: Seq[String] = ImageContentType.values.map(c => c.entryName) val IsoMappingCacheAgeInMs: Int = 1000 * 60 * 60 // 1 hour caching val LicenseMappingCacheAgeInMs: Int = 1000 * 60 * 60 // 1 hour caching diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageContentType.scala b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageContentType.scala new file mode 100644 index 0000000000..2b0e0d847a --- /dev/null +++ b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageContentType.scala @@ -0,0 +1,33 @@ +/* + * Part of NDLA image-api + * Copyright (C) 2026 NDLA + * + * See LICENSE + * + */ + +package no.ndla.imageapi.model.domain + +import enumeratum.{Enum, EnumEntry} +import no.ndla.common.CirceUtil.CirceEnumWithErrors + +sealed abstract class ImageContentType(override val entryName: String, val fileEndings: List[String]) + extends EnumEntry { + override def toString: String = entryName +} + +object ImageContentType extends Enum[ImageContentType], CirceEnumWithErrors[ImageContentType] { + case object Jpeg extends ImageContentType("image/jpeg", List(".jpg", ".jpeg")) + case object JpegCitrix extends ImageContentType("image/x-citrix-jpeg", List(".jpg", ".jpeg")) + case object JpegProgressive extends ImageContentType("image/pjpeg", List(".jpg", ".jpeg")) + case object Png extends ImageContentType("image/png", List(".png")) + case object PngXToken extends ImageContentType("image/x-png", List(".png")) + case object Gif extends ImageContentType("image/gif", List(".gif")) + case object Webp extends ImageContentType("image/webp", List(".webp")) + case object Svg extends ImageContentType("image/svg+xml", List(".svg")) + case object Bmp extends ImageContentType("image/bmp", List(".bmp")) + + override def values: IndexedSeq[ImageContentType] = findValues + + def valueOf(s: String): Option[ImageContentType] = ImageContentType.values.find(_.entryName == s) +} diff --git a/image-api/src/main/scala/no/ndla/imageapi/service/ValidationService.scala b/image-api/src/main/scala/no/ndla/imageapi/service/ValidationService.scala index 5cd550bc63..51ae8ca0e1 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/service/ValidationService.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/service/ValidationService.scala @@ -23,28 +23,38 @@ import scala.util.{Failure, Success, Try} class ValidationService(using props: Props) { def validateImageFile(imageFile: UploadedFile): Option[ValidationMessage] = { - val fn = imageFile.fileName.getOrElse("").stripPrefix("\"").stripSuffix("\"") - if (!hasValidFileExtension(fn, props.ValidFileExtensions)) return Some( - ValidationMessage( - "file", - s"The file $fn does not have a known file extension. Must be one of ${props.ValidFileExtensions.mkString(",")}", - ) - ) - + val fn = imageFile.fileName.getOrElse("").stripPrefix("\"").stripSuffix("\"") val actualMimeType = imageFile.contentType.getOrElse("") - if (!props.ValidMimeTypes.contains(actualMimeType)) return Some( - ValidationMessage( - "file", - s"The file $fn is not a valid image file. Only valid type is '${props.ValidMimeTypes.mkString(",")}', but was '$actualMimeType'", - ) - ) - - None + // Find the ImageContentType for the provided mime type + ImageContentType.valueOf(actualMimeType) match { + case None => Some( + ValidationMessage( + "file", + s"The file $fn has an invalid content type '$actualMimeType'. Must be one of ${props.ValidMimeTypes.mkString(", ")}", + ) + ) + case Some(contentType) => + // Verify that the file extension matches the content type + val fileExtension = getFileExtension(fn) + if (!contentType.fileEndings.contains(fileExtension)) { + Some( + ValidationMessage( + "file", + s"The file extension '$fileExtension' does not match the content type '$actualMimeType'. Expected one of ${contentType.fileEndings.mkString(", ")}", + ) + ) + } else { + None + } + } } - private def hasValidFileExtension(filename: String, extensions: Seq[String]): Boolean = { - extensions.exists(extension => filename.toLowerCase.endsWith(extension)) + private def getFileExtension(filename: String): String = { + val lowerFilename = filename.toLowerCase + val dotIndex = lowerFilename.lastIndexOf('.') + if (dotIndex >= 0) lowerFilename.substring(dotIndex) + else "" } def validate(image: ImageMetaInformation, oldImage: Option[ImageMetaInformation]): Try[ImageMetaInformation] = { diff --git a/image-api/src/test/scala/no/ndla/imageapi/service/ValidationServiceTest.scala b/image-api/src/test/scala/no/ndla/imageapi/service/ValidationServiceTest.scala index d3786b186c..93c6fc4ba8 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/service/ValidationServiceTest.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/service/ValidationServiceTest.scala @@ -65,9 +65,10 @@ class ValidationServiceTest extends UnitSuite with TestEnvironment { test("validateImageFile returns a validation message if file has an unknown extension") { val fileName = "image.asdf" when(fileMock.fileName).thenReturn(Some(fileName)) + when(fileMock.contentType).thenReturn(Some("image/jpeg")) val Some(result) = validationService.validateImageFile(fileMock): @unchecked - result.message.contains(s"The file $fileName does not have a known file extension") should be(true) + result.message.contains(s"The file extension '.asdf' does not match the content type") should be(true) } test("validateImageFile returns a validation message if content type is unknown") { @@ -76,7 +77,7 @@ class ValidationServiceTest extends UnitSuite with TestEnvironment { when(fileMock.contentType).thenReturn(Some("text/html")) val Some(result) = validationService.validateImageFile(fileMock): @unchecked - result.message.contains(s"The file $fileName is not a valid image file.") should be(true) + result.message.contains(s"The file $fileName has an invalid content type") should be(true) } test("validateImageFile returns None if image file is valid") { From 0bc1a85e99c42da0ac148c347d722dae0d607a4e Mon Sep 17 00:00:00 2001 From: Gunnar Velle Date: Wed, 25 Feb 2026 10:44:53 +0100 Subject: [PATCH 5/6] Handle all image mime types as enum --- .../no/ndla/imageapi/ImageApiProperties.scala | 2 +- .../imageapi/controller/RawController.scala | 6 ++- .../no/ndla/imageapi/model/NDLAErrors.scala | 5 ++- .../imageapi/model/api/ImageFileDTO.scala | 3 +- .../model/api/ImageMetaInformationV2DTO.scala | 3 +- .../model/domain/ImageApiModels.scala | 2 +- .../model/domain/ImageContentType.scala | 14 ++++--- .../imageapi/model/domain/ImageFileData.scala | 2 +- .../imageapi/model/domain/ImageStream.scala | 11 +++-- .../model/domain/ProcessableImageFormat.scala | 8 ++-- .../imageapi/service/ConverterService.scala | 8 ++-- .../imageapi/service/ImageConverter.scala | 33 +++++++++------ .../service/ImageStorageService.scala | 37 ++++++++--------- .../ndla/imageapi/service/WriteService.scala | 2 +- .../search/SearchConverterService.scala | 2 +- .../scala/no/ndla/imageapi/TestData.scala | 32 +++++++-------- .../controller/HealthControllerTest.scala | 3 +- .../controller/ImageControllerV2Test.scala | 2 +- .../controller/InternControllerTest.scala | 6 +-- .../service/ConverterServiceTest.scala | 8 ++-- .../service/ImageStorageServiceTest.scala | 13 +++--- .../imageapi/service/ReadServiceTest.scala | 4 +- .../service/ValidationServiceTest.scala | 2 +- .../imageapi/service/WriteServiceTest.scala | 40 ++++++++++++++----- .../search/ImageSearchServiceTest.scala | 22 ++++++---- typescript/types-backend/image-api-openapi.ts | 6 +++ .../types-backend/openapi/image-api.json | 2 +- 27 files changed, 166 insertions(+), 112 deletions(-) diff --git a/image-api/src/main/scala/no/ndla/imageapi/ImageApiProperties.scala b/image-api/src/main/scala/no/ndla/imageapi/ImageApiProperties.scala index 85570614d5..598d375dd8 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/ImageApiProperties.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/ImageApiProperties.scala @@ -32,7 +32,7 @@ class ImageApiProperties extends BaseProps with DatabaseProps with StrictLogging val ImageControllerV3Path: String = s"$ImageApiBasePath/v3/images" val RawControllerPath: String = s"$ImageApiBasePath/raw" - val ValidMimeTypes: Seq[String] = ImageContentType.values.map(c => c.entryName) + val ValidMimeTypes: Seq[ImageContentType] = ImageContentType.values.filter(_.image) val IsoMappingCacheAgeInMs: Int = 1000 * 60 * 60 // 1 hour caching val LicenseMappingCacheAgeInMs: Int = 1000 * 60 * 60 // 1 hour caching diff --git a/image-api/src/main/scala/no/ndla/imageapi/controller/RawController.scala b/image-api/src/main/scala/no/ndla/imageapi/controller/RawController.scala index a59c434f75..23c3b718bd 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/controller/RawController.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/controller/RawController.scala @@ -38,8 +38,10 @@ class RawController(using override val endpoints: List[ServerEndpoint[Any, Eff]] = List(getImageFileById, getImageFile) private def toImageResponse(image: ImageStream): Either[AllErrors, (DynamicHeaders, InputStream)] = { - val headers = - DynamicHeaders.fromValues("Content-Type" -> image.contentType, "Content-Length" -> image.contentLength.toString) + val headers = DynamicHeaders.fromValues( + "Content-Type" -> image.contentType.toString, + "Content-Length" -> image.contentLength.toString, + ) Right(headers -> image.stream) } diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/NDLAErrors.scala b/image-api/src/main/scala/no/ndla/imageapi/model/NDLAErrors.scala index 7f7ee3cd12..6fdceb89e6 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/model/NDLAErrors.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/model/NDLAErrors.scala @@ -10,6 +10,7 @@ package no.ndla.imageapi.model import no.ndla.common.errors.MultipleExceptions import no.ndla.imageapi.Props +import no.ndla.imageapi.model.domain.ImageContentType class ImageNotFoundException(message: String) extends RuntimeException(message) @@ -23,8 +24,8 @@ case class ImageDeleteException(message: String, exs: Seq[Throwable]) ex case class ImageVariantsUploadException(message: String, exs: Seq[Throwable]) extends MultipleExceptions(message, exs) case class ImageConversionException(message: String) extends RuntimeException(message) case class ImageCopyException(message: String) extends RuntimeException(message) -case class ImageUnprocessableFormatException(contentType: String) - extends RuntimeException(s"Image of '$contentType' Content-Type did not have a processable binary format") +case class ImageUnprocessableFormatException(contentType: ImageContentType) + extends RuntimeException(s"Image of '${contentType}' Content-Type did not have a processable binary format") object ImageErrorHelpers { def fileTooBigError(using props: Props): String = diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/api/ImageFileDTO.scala b/image-api/src/main/scala/no/ndla/imageapi/model/api/ImageFileDTO.scala index d86454e438..1b70a533e7 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/model/api/ImageFileDTO.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/model/api/ImageFileDTO.scala @@ -10,6 +10,7 @@ package no.ndla.imageapi.model.api import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} import io.circe.{Decoder, Encoder} +import no.ndla.imageapi.model.domain.ImageContentType import no.ndla.language.Language.LanguageDocString import sttp.tapir.Schema.annotations.description @@ -20,7 +21,7 @@ case class ImageFileDTO( @description("The size of the image in bytes") size: Long, @description("The mimetype of the image") - contentType: String, + contentType: ImageContentType, @description("The full url to where the image can be downloaded") imageUrl: String, @description("Dimensions of the image") diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/api/ImageMetaInformationV2DTO.scala b/image-api/src/main/scala/no/ndla/imageapi/model/api/ImageMetaInformationV2DTO.scala index a41917ec34..cf4406e2cd 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/model/api/ImageMetaInformationV2DTO.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/model/api/ImageMetaInformationV2DTO.scala @@ -12,6 +12,7 @@ import io.circe.{Decoder, Encoder} import io.circe.generic.semiauto.{deriveDecoder, deriveEncoder} import no.ndla.common.model.NDLADate import no.ndla.common.model.api.CopyrightDTO +import no.ndla.imageapi.model.domain.ImageContentType import sttp.tapir.Schema.annotations.description @description("Meta information for the image") @@ -29,7 +30,7 @@ case class ImageMetaInformationV2DTO( @description("The size of the image in bytes") size: Long, @description("The mimetype of the image") - contentType: String, + contentType: ImageContentType, @description("Describes the copyright information for the image") copyright: CopyrightDTO, @description("Searchable tags for the image") diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageApiModels.scala b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageApiModels.scala index c3982fdc43..d313b1d14b 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageApiModels.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageApiModels.scala @@ -55,7 +55,7 @@ object ImageCaption { case class UploadedImage( fileName: String, size: Long, - contentType: String, + contentType: ImageContentType, dimensions: Option[ImageDimensions], variants: Seq[ImageVariant], ) diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageContentType.scala b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageContentType.scala index 2b0e0d847a..60aa3adfd4 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageContentType.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageContentType.scala @@ -11,21 +11,25 @@ package no.ndla.imageapi.model.domain import enumeratum.{Enum, EnumEntry} import no.ndla.common.CirceUtil.CirceEnumWithErrors -sealed abstract class ImageContentType(override val entryName: String, val fileEndings: List[String]) - extends EnumEntry { +sealed abstract class ImageContentType( + override val entryName: String, + val fileEndings: List[String], + val image: Boolean = true, +) extends EnumEntry { override def toString: String = entryName } object ImageContentType extends Enum[ImageContentType], CirceEnumWithErrors[ImageContentType] { + case object Binary extends ImageContentType("binary/octet-stream", List(), false) + case object Bmp extends ImageContentType("image/bmp", List(".bmp")) + case object Gif extends ImageContentType("image/gif", List(".gif")) case object Jpeg extends ImageContentType("image/jpeg", List(".jpg", ".jpeg")) case object JpegCitrix extends ImageContentType("image/x-citrix-jpeg", List(".jpg", ".jpeg")) case object JpegProgressive extends ImageContentType("image/pjpeg", List(".jpg", ".jpeg")) case object Png extends ImageContentType("image/png", List(".png")) case object PngXToken extends ImageContentType("image/x-png", List(".png")) - case object Gif extends ImageContentType("image/gif", List(".gif")) - case object Webp extends ImageContentType("image/webp", List(".webp")) case object Svg extends ImageContentType("image/svg+xml", List(".svg")) - case object Bmp extends ImageContentType("image/bmp", List(".bmp")) + case object Webp extends ImageContentType("image/webp", List(".webp")) override def values: IndexedSeq[ImageContentType] = findValues diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageFileData.scala b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageFileData.scala index 67a1bd1116..d05cf389c7 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageFileData.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageFileData.scala @@ -15,7 +15,7 @@ import no.ndla.language.model.WithLanguage case class ImageFileData( fileName: String, size: Long, - contentType: String, + contentType: ImageContentType, dimensions: Option[ImageDimensions], variants: Seq[ImageVariant], override val language: String, diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageStream.scala b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageStream.scala index d4bcb2fbe9..2bb885cc89 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageStream.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageStream.scala @@ -18,7 +18,12 @@ import scala.util.Using.Releasable * The underlying `InputStream` should be closed after being used, either by closing it directly or with * [[scala.util.Using]]. Failing to close the stream may lead to resource leaks/starvation. */ -enum ImageStream(val stream: InputStream, val fileName: String, val contentLength: Long, val contentType: String) { +enum ImageStream( + val stream: InputStream, + val fileName: String, + val contentLength: Long, + val contentType: ImageContentType, +) { case Processable( override val stream: InputStream, override val fileName: String, @@ -27,13 +32,13 @@ enum ImageStream(val stream: InputStream, val fileName: String, val contentLengt ) extends ImageStream(stream, fileName, contentLength, format.toContentType) case Gif(override val stream: InputStream, override val fileName: String, override val contentLength: Long) - extends ImageStream(stream, fileName, contentLength, "image/gif") + extends ImageStream(stream, fileName, contentLength, ImageContentType.Gif) case Unprocessable( override val stream: InputStream, override val fileName: String, override val contentLength: Long, - override val contentType: String, + override val contentType: ImageContentType, ) extends ImageStream(stream, fileName, contentLength, contentType) } diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ProcessableImageFormat.scala b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ProcessableImageFormat.scala index fceca13d88..40dba6c62c 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ProcessableImageFormat.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ProcessableImageFormat.scala @@ -11,10 +11,10 @@ package no.ndla.imageapi.model.domain import enumeratum.{Enum, EnumEntry} sealed trait ProcessableImageFormat extends EnumEntry { - def toContentType: String = this match { - case ProcessableImageFormat.Jpeg => "image/jpeg" - case ProcessableImageFormat.Png => "image/png" - case ProcessableImageFormat.Webp => "image/webp" + def toContentType: ImageContentType = this match { + case ProcessableImageFormat.Jpeg => ImageContentType.Jpeg + case ProcessableImageFormat.Png => ImageContentType.Png + case ProcessableImageFormat.Webp => ImageContentType.Webp } } diff --git a/image-api/src/main/scala/no/ndla/imageapi/service/ConverterService.scala b/image-api/src/main/scala/no/ndla/imageapi/service/ConverterService.scala index 90b1c81beb..fa51b70f4a 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/service/ConverterService.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/service/ConverterService.scala @@ -213,18 +213,18 @@ class ConverterService(using clock: Clock, props: Props) extends StrictLogging { }) } - def asApiImageTag(domainImageTag: commonDomain.Tag): api.ImageTagDTO = { + private def asApiImageTag(domainImageTag: commonDomain.Tag): api.ImageTagDTO = { api.ImageTagDTO(domainImageTag.tags, domainImageTag.language) } - def asApiCaption(domainImageCaption: domain.ImageCaption): api.ImageCaptionDTO = + private def asApiCaption(domainImageCaption: domain.ImageCaption): api.ImageCaptionDTO = api.ImageCaptionDTO(domainImageCaption.caption, domainImageCaption.language) - def asApiImageTitle(domainImageTitle: domain.ImageTitle): api.ImageTitleDTO = { + private def asApiImageTitle(domainImageTitle: domain.ImageTitle): api.ImageTitleDTO = { api.ImageTitleDTO(domainImageTitle.title, domainImageTitle.language) } - def asApiLicense(license: String): commonApi.LicenseDTO = { + private def asApiLicense(license: String): commonApi.LicenseDTO = { getLicense(license) .map(l => commonApi.LicenseDTO(l.license.toString, Some(l.description), l.url)) .getOrElse(commonApi.LicenseDTO("unknown", None, None)) diff --git a/image-api/src/main/scala/no/ndla/imageapi/service/ImageConverter.scala b/image-api/src/main/scala/no/ndla/imageapi/service/ImageConverter.scala index 67c64c12da..7168767bad 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/service/ImageConverter.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/service/ImageConverter.scala @@ -18,6 +18,7 @@ import no.ndla.common.model.domain.UploadedFile import no.ndla.imageapi.Props import no.ndla.imageapi.model.ImageUnprocessableFormatException import no.ndla.imageapi.model.domain.* +import no.ndla.imageapi.model.domain.ImageContentType import java.io.{BufferedInputStream, InputStream} import java.lang.Math.{abs, max, min} @@ -46,28 +47,34 @@ object PercentPoint { } class ImageConverter(using props: Props) extends StrictLogging { - private val svgMimeTypes = List("image/svg", "image/svg+xml") - - def s3ObjectToImageStream(s3Object: NdlaS3Object): Try[ImageStream] = - inputStreamToImageStream(s3Object.stream, s3Object.key, s3Object.contentLength, s3Object.contentType) + def s3ObjectToImageStream(s3Object: NdlaS3Object): Try[ImageStream] = inputStreamToImageStream( + s3Object.stream, + s3Object.key, + s3Object.contentLength, + ImageContentType.valueOf(s3Object.contentType).getOrElse(ImageContentType.Binary), + ) - def uploadedFileToImageStream(file: UploadedFile, fileName: String): Try[ImageStream] = - inputStreamToImageStream(file.createStream(), fileName, file.fileSize, file.contentType.getOrElse("")) + def uploadedFileToImageStream(file: UploadedFile, fileName: String): Try[ImageStream] = inputStreamToImageStream( + file.createStream(), + fileName, + file.fileSize, + file.contentType.flatMap(ImageContentType.valueOf).getOrElse(ImageContentType.Binary), + ) private def maybeScrimageFormatToImageStream( stream: BufferedInputStream, fileName: String, contentLength: Long, - contentType: String, + contentType: ImageContentType, maybeScrimageFormat: Try[Option[Format]], ): Try[ImageStream] = { import ImageStream.* maybeScrimageFormat.flatMap { - case Some(Format.GIF) => Success(Gif(stream, fileName, contentLength)) - case Some(Format.PNG) => Success(Processable(stream, fileName, contentLength, ProcessableImageFormat.Png)) - case Some(Format.JPEG) => Success(Processable(stream, fileName, contentLength, ProcessableImageFormat.Jpeg)) - case Some(Format.WEBP) => Success(Processable(stream, fileName, contentLength, ProcessableImageFormat.Webp)) - case None if svgMimeTypes.contains(contentType) => + case Some(Format.GIF) => Success(Gif(stream, fileName, contentLength)) + case Some(Format.PNG) => Success(Processable(stream, fileName, contentLength, ProcessableImageFormat.Png)) + case Some(Format.JPEG) => Success(Processable(stream, fileName, contentLength, ProcessableImageFormat.Jpeg)) + case Some(Format.WEBP) => Success(Processable(stream, fileName, contentLength, ProcessableImageFormat.Webp)) + case None if contentType == ImageContentType.Svg => Success(Unprocessable(stream, fileName, contentLength, contentType)) case None => Failure(ImageUnprocessableFormatException(contentType)) } @@ -77,7 +84,7 @@ class ImageConverter(using props: Props) extends StrictLogging { inputStream: InputStream, fileName: String, contentLength: Long, - contentType: String, + contentType: ImageContentType, ): Try[ImageStream] = Try .throwIfInterrupted { // Use buffered stream with mark to avoid creating multiple streams diff --git a/image-api/src/main/scala/no/ndla/imageapi/service/ImageStorageService.scala b/image-api/src/main/scala/no/ndla/imageapi/service/ImageStorageService.scala index e1bdb093d7..55cc221401 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/service/ImageStorageService.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/service/ImageStorageService.scala @@ -13,7 +13,7 @@ import cats.implicits.* import com.typesafe.scalalogging.StrictLogging import no.ndla.common.aws.{NdlaS3Client, NdlaS3Object} import no.ndla.imageapi.Props -import no.ndla.imageapi.model.domain.ImageStream +import no.ndla.imageapi.model.domain.{ImageContentType, ImageStream} import java.io.InputStream import scala.util.{Failure, Success, Try} @@ -24,18 +24,15 @@ class ImageStorageService(using imageConverter: ImageConverter, props: Props, ) extends StrictLogging { - private def ensureS3ContentType(s3Object: NdlaS3Object): String = { - val s3ContentType = s3Object.contentType + private def ensureS3ContentType(s3Object: NdlaS3Object): ImageContentType = { + val s3ContentType = ImageContentType.valueOf(s3Object.contentType) val fileName = s3Object.key - if (s3ContentType == "binary/octet-stream") { + if (s3ContentType.contains(ImageContentType.Binary)) { readService.getImageFileFromFilePath(fileName) match { case Failure(ex) => - logger.warn(s"Couldn't get meta for $fileName so using s3 content-type of '$s3ContentType'", ex) - s3ContentType - case Success(meta) - if meta.contentType != "" && meta.contentType != "binary/octet-stream" && props - .ValidMimeTypes - .contains(meta.contentType) => + logger.warn(s"Couldn't get meta for $fileName so using s3 content-type of '$s3ContentType.get'", ex) + s3ContentType.get + case Success(meta) if props.ValidMimeTypes.contains(meta.contentType) => updateContentType(s3Object.key, meta.contentType) match { case Failure(ex) => logger.error(s"Could not update content-type s3-metadata of $fileName to ${meta.contentType}", ex) @@ -43,9 +40,9 @@ class ImageStorageService(using logger.info(s"Successfully updated content-type s3-metadata of $fileName to ${meta.contentType}") } meta.contentType - case _ => s3ContentType + case _ => s3ContentType.get } - } else s3ContentType + } else s3ContentType.get } def get(imageKey: String): Try[ImageStream] = { @@ -58,15 +55,19 @@ class ImageStorageService(using def getRaw(bucketKey: String): Try[NdlaS3Object] = s3Client.getObject(bucketKey) - def uploadFromStream(storageKey: String, stream: InputStream, contentLength: Long, contentType: String): Try[String] = - s3Client - .putObject(storageKey, stream, contentLength, contentType, props.S3NewFileCacheControlHeader.some) - .map(_ => storageKey) + def uploadFromStream( + storageKey: String, + stream: InputStream, + contentLength: Long, + contentType: ImageContentType, + ): Try[String] = s3Client + .putObject(storageKey, stream, contentLength, contentType.toString, props.S3NewFileCacheControlHeader.some) + .map(_ => storageKey) - def updateContentType(storageKey: String, contentType: String): Try[Unit] = for { + def updateContentType(storageKey: String, contentType: ImageContentType): Try[Unit] = for { meta <- s3Client.headObject(storageKey) metadata = meta.metadata() - _ = metadata.put("Content-Type", contentType) + _ = metadata.put("Content-Type", contentType.toString) _ <- s3Client.updateMetadata(storageKey, metadata) } yield () diff --git a/image-api/src/main/scala/no/ndla/imageapi/service/WriteService.scala b/image-api/src/main/scala/no/ndla/imageapi/service/WriteService.scala index 101e5ccbff..80be1bfe46 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/service/WriteService.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/service/WriteService.scala @@ -584,7 +584,7 @@ class WriteService(using resizedImage.toProcessableStreamWithWriter(getWebpWriterForFormat(format), ProcessableImageFormat.Webp) bucketKey = s"$fileStem/${variantSize.entryName}.webp" imageVariant <- imageStorage - .uploadFromStream(bucketKey, webpStream.stream, webpStream.contentLength, "image/webp") + .uploadFromStream(bucketKey, webpStream.stream, webpStream.contentLength, ImageContentType.Webp) .map(_ => ImageVariant(variantSize, bucketKey)) } yield imageVariant } diff --git a/image-api/src/main/scala/no/ndla/imageapi/service/search/SearchConverterService.scala b/image-api/src/main/scala/no/ndla/imageapi/service/search/SearchConverterService.scala index e4f19592e7..0880cd4557 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/service/search/SearchConverterService.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/service/search/SearchConverterService.scala @@ -39,7 +39,7 @@ class SearchConverterService(using converterService: ConverterService, props: Pr imageSize = i.size, previewUrl = parse("/" + i.fileName.dropWhile(_ == '/')).toString, fileSize = i.size, - contentType = i.contentType, + contentType = i.contentType.toString, dimensions = i.dimensions, language = i.language, ) diff --git a/image-api/src/test/scala/no/ndla/imageapi/TestData.scala b/image-api/src/test/scala/no/ndla/imageapi/TestData.scala index 37c29ee5da..f5105dcd20 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/TestData.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/TestData.scala @@ -34,7 +34,7 @@ class TestData(using props: Props) { ImageFileData( fileName = "Elg.jpg", size = 2865539, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", @@ -68,7 +68,7 @@ class TestData(using props: Props) { api.ImageAltTextDTO("Elg i busk", "nb"), "Elg.jpg", 2865539, - "image/jpeg", + ImageContentType.Jpeg, commonApi.CopyrightDTO( commonApi.LicenseDTO( License.CC_BY_NC_SA.toString, @@ -100,7 +100,7 @@ class TestData(using props: Props) { alttext = api.ImageAltTextDTO("Elg i busk", "nb"), imageUrl = "", size = 141134, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, copyright = commonApi.CopyrightDTO( commonApi.LicenseDTO( License.CC_BY_NC_SA.toString, @@ -133,7 +133,7 @@ class TestData(using props: Props) { new ImageFileData( fileName = "Bjørn.jpg", size = 14113, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", @@ -168,7 +168,7 @@ class TestData(using props: Props) { new ImageFileData( fileName = "Jerv.jpg", size = 39061, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", @@ -203,7 +203,7 @@ class TestData(using props: Props) { new ImageFileData( fileName = "Mink.jpg", size = 102559, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", @@ -238,7 +238,7 @@ class TestData(using props: Props) { new ImageFileData( fileName = "Rein.jpg", size = 504911, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", @@ -273,7 +273,7 @@ class TestData(using props: Props) { new ImageFileData( fileName = "Krokodille.jpg", size = 2865539, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", @@ -308,7 +308,7 @@ class TestData(using props: Props) { new ImageFileData( fileName = "Bison.jpg", size = 2865539, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", @@ -337,28 +337,28 @@ class TestData(using props: Props) { val testdata: List[ImageMetaInformation] = List(elg, bjorn, jerv, mink, rein) - def mockS3ObjectFromDisk(fileName: String, contentType: String): NdlaS3Object = { + def mockS3ObjectFromDisk(fileName: String, contentType: ImageContentType): NdlaS3Object = { val bytes = getClass.getResourceAsStream(s"/$fileName").readAllBytes() val stream = new ByteArrayInputStream(bytes) - NdlaS3Object("", fileName, stream, contentType, bytes.length) + NdlaS3Object("", fileName, stream, contentType.toString, bytes.length) } private val imageConverter: ImageConverter = new ImageConverter - def ndlaLogoImageS3Object: NdlaS3Object = mockS3ObjectFromDisk("ndla_logo.jpg", "image/jpeg") + def ndlaLogoImageS3Object: NdlaS3Object = mockS3ObjectFromDisk("ndla_logo.jpg", ImageContentType.Jpeg) def ndlaLogoImageStream: ImageStream.Processable = imageConverter .s3ObjectToImageStream(ndlaLogoImageS3Object) .get .asInstanceOf[ImageStream.Processable] val NdlaLogoImage: ProcessableImage = ProcessableImage.fromStream(ndlaLogoImageStream).get - def ndlaLogoGIFImageS3Object: NdlaS3Object = mockS3ObjectFromDisk("ndla_logo.gif", "image/gif") + def ndlaLogoGIFImageS3Object: NdlaS3Object = mockS3ObjectFromDisk("ndla_logo.gif", ImageContentType.Gif) def ndlaLogoGifImageStream: ImageStream.Gif = imageConverter .s3ObjectToImageStream(ndlaLogoGIFImageS3Object) .get .asInstanceOf[ImageStream.Gif] - def ccLogoSvgImageS3Object: NdlaS3Object = mockS3ObjectFromDisk("cc.svg", "image/svg+xml") + def ccLogoSvgImageS3Object: NdlaS3Object = mockS3ObjectFromDisk("cc.svg", ImageContentType.Svg) def ccLogoSvgImageStream: ImageStream.Unprocessable = imageConverter .s3ObjectToImageStream(ccLogoSvgImageS3Object) .get @@ -366,7 +366,7 @@ class TestData(using props: Props) { // From https://pixabay.com/en/children-drawing-home-tree-meadow-582306/ private val childrensImageFileName = "children-drawing-582306_640.jpg" - private val childrensImageS3Object = mockS3ObjectFromDisk(childrensImageFileName, "image/jpeg") + private val childrensImageS3Object = mockS3ObjectFromDisk(childrensImageFileName, ImageContentType.Jpeg) private val childrensImageStream = imageConverter .s3ObjectToImageStream(childrensImageS3Object) .get @@ -375,7 +375,7 @@ class TestData(using props: Props) { val childrensImageUploadedFile: UploadedFile = { val file = new File(getClass.getResource(s"/$childrensImageFileName").toURI) - UploadedFile("file", Some(childrensImageFileName), file.length(), Some("image/jpeg"), file) + UploadedFile("file", Some(childrensImageFileName), file.length(), Some(ImageContentType.Jpeg.toString), file) } val searchSettings: SearchSettings = SearchSettings( diff --git a/image-api/src/test/scala/no/ndla/imageapi/controller/HealthControllerTest.scala b/image-api/src/test/scala/no/ndla/imageapi/controller/HealthControllerTest.scala index d5188f1a4b..d32a151240 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/controller/HealthControllerTest.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/controller/HealthControllerTest.scala @@ -16,6 +16,7 @@ import no.ndla.common.model.domain.article.Copyright import no.ndla.imageapi.model.domain.{ ImageAltText, ImageCaption, + ImageContentType, ImageDimensions, ImageFileData, ImageMetaInformation, @@ -58,7 +59,7 @@ class HealthControllerTest extends UnitSuite with TestEnvironment with TapirCont Some(1), Seq(ImageTitle("Batmen er på vift med en bil", "nb")), Seq(ImageAltText("Batmen er på vift med en bil", "nb")), - Seq(ImageFileData("file.jpg", 1024, "image/jpg", Some(ImageDimensions(1, 1)), Seq.empty, "nb")), + Seq(ImageFileData("file.jpg", 1024, ImageContentType.Jpeg, Some(ImageDimensions(1, 1)), Seq.empty, "nb")), copyrighted, Seq.empty, Seq(ImageCaption("Batmen er på vift med en bil", "nb")), diff --git a/image-api/src/test/scala/no/ndla/imageapi/controller/ImageControllerV2Test.scala b/image-api/src/test/scala/no/ndla/imageapi/controller/ImageControllerV2Test.scala index 5e60502c72..fb788df30a 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/controller/ImageControllerV2Test.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/controller/ImageControllerV2Test.scala @@ -243,7 +243,7 @@ class ImageControllerV2Test extends UnitSuite with TestEnvironment with TapirCon new ImageFileData( fileName = "/img.jpg", size = 1024, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "und", diff --git a/image-api/src/test/scala/no/ndla/imageapi/controller/InternControllerTest.scala b/image-api/src/test/scala/no/ndla/imageapi/controller/InternControllerTest.scala index 4109bd73d2..4a10bf2827 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/controller/InternControllerTest.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/controller/InternControllerTest.scala @@ -13,7 +13,7 @@ import no.ndla.common.model.domain.article.Copyright import no.ndla.common.model.{NDLADate, api as commonApi} import no.ndla.imageapi.model.api import no.ndla.imageapi.model.api.{ImageAltTextDTO, ImageCaptionDTO, ImageTagDTO, ImageTitleDTO} -import no.ndla.imageapi.model.domain.{ImageFileData, ImageMetaInformation, ModelReleasedStatus} +import no.ndla.imageapi.model.domain.{ImageContentType, ImageFileData, ImageMetaInformation, ModelReleasedStatus} import no.ndla.imageapi.service.ConverterService import no.ndla.imageapi.{TestEnvironment, UnitSuite} import no.ndla.mapping.License.{CC_BY, getLicense} @@ -45,7 +45,7 @@ class InternControllerTest extends UnitSuite with TestEnvironment with TapirCont ImageAltTextDTO("", "nb"), s"${props.RawImageUrlBase}/test.jpg", 0, - "", + ImageContentType.Jpeg, commonApi.CopyrightDTO( commonApi.LicenseDTO(BySa.license.toString, Some(BySa.description), BySa.url), None, @@ -74,7 +74,7 @@ class InternControllerTest extends UnitSuite with TestEnvironment with TapirCont new ImageFileData( fileName = "test.jpg", size = 0, - contentType = "", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "und", diff --git a/image-api/src/test/scala/no/ndla/imageapi/service/ConverterServiceTest.scala b/image-api/src/test/scala/no/ndla/imageapi/service/ConverterServiceTest.scala index ff10e48648..fbc0751bd8 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/service/ConverterServiceTest.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/service/ConverterServiceTest.scala @@ -23,8 +23,8 @@ class ConverterServiceTest extends UnitSuite with TestEnvironment { val updated: NDLADate = NDLADate.of(2017, 4, 1, 12, 15, 32) val someDims: Some[ImageDimensions] = Some(ImageDimensions(100, 100)) - val full = new ImageFileData("/123.png", 200, "image/png", someDims, Seq.empty, "nb") - val wanting = new ImageFileData("123.png", 200, "image/png", someDims, Seq.empty, "und") + val full = new ImageFileData("/123.png", 200, ImageContentType.Png, someDims, Seq.empty, "nb") + val wanting = new ImageFileData("123.png", 200, ImageContentType.Png, someDims, Seq.empty, "und") val DefaultImageMetaInformation = new ImageMetaInformation( id = Some(1), @@ -181,7 +181,7 @@ class ConverterServiceTest extends UnitSuite with TestEnvironment { val newImage = new ImageFileData( fileName = "somename.jpg", size = 123, - contentType = "image/jpg", + contentType = ImageContentType.Jpeg, dimensions = Some(ImageDimensions(123, 555)), variants = Seq.empty, language = "nb", @@ -190,7 +190,7 @@ class ConverterServiceTest extends UnitSuite with TestEnvironment { val result = converterService.withNewImageFile(MultiLangImage, newImage, "nb", TokenUser.SystemUser) result.images.find(_.language == "nb").get.size should be(123) result.images.find(_.language == "nb").get.dimensions should be(Some(ImageDimensions(123, 555))) - result.images.find(_.language == "nb").get.contentType should be("image/jpg") + result.images.find(_.language == "nb").get.contentType should be(ImageContentType.Jpeg) } } diff --git a/image-api/src/test/scala/no/ndla/imageapi/service/ImageStorageServiceTest.scala b/image-api/src/test/scala/no/ndla/imageapi/service/ImageStorageServiceTest.scala index 34bfdfa9a9..43b23bf123 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/service/ImageStorageServiceTest.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/service/ImageStorageServiceTest.scala @@ -9,6 +9,7 @@ package no.ndla.imageapi.service import no.ndla.common.aws.NdlaS3Object +import no.ndla.imageapi.model.domain.{ImageContentType, ImageMetaInformation} import no.ndla.imageapi.service.ImageStorageService import no.ndla.imageapi.{TestEnvironment, UnitSuite} import org.mockito.ArgumentMatchers.any @@ -19,11 +20,11 @@ import scala.util.{Failure, Success} class ImageStorageServiceTest extends UnitSuite with TestEnvironment { - val ImageStorageName = props.StorageName - val ImageWithNoThumb = TestData.nonexistingWithoutThumb - val Content = "content" - val ContentType = "image/jpeg" - override lazy val imageStorage = new ImageStorageService + val ImageStorageName: String = props.StorageName + val ImageWithNoThumb: ImageMetaInformation = TestData.nonexistingWithoutThumb + val Content = "content" + val ContentType: ImageContentType = ImageContentType.Jpeg + override lazy val imageStorage = new ImageStorageService override def beforeEach(): Unit = { reset(s3Client) @@ -40,7 +41,7 @@ class ImageStorageServiceTest extends UnitSuite with TestEnvironment { } test("That AmazonImageStorage.get returns a tuple with contenttype and data when the key exists") { - val s3Object = NdlaS3Object("bucket", "existing", TestData.ndlaLogoImageStream.stream, ContentType, 0) + val s3Object = NdlaS3Object("bucket", "existing", TestData.ndlaLogoImageStream.stream, ContentType.toString, 0) when(s3Client.getObject(any)).thenReturn(Success(s3Object)) val image = imageStorage.get("existing").failIfFailure diff --git a/image-api/src/test/scala/no/ndla/imageapi/service/ReadServiceTest.scala b/image-api/src/test/scala/no/ndla/imageapi/service/ReadServiceTest.scala index 78d3156359..72ccf699c7 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/service/ReadServiceTest.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/service/ReadServiceTest.scala @@ -13,7 +13,7 @@ import no.ndla.common.model.domain.article.Copyright import no.ndla.common.model.domain as common import no.ndla.common.model.domain.ContributorType import no.ndla.imageapi.model.api.ImageMetaInformationV2DTO -import no.ndla.imageapi.model.domain.{ImageFileData, ImageMetaInformation, ModelReleasedStatus} +import no.ndla.imageapi.model.domain.{ImageContentType, ImageFileData, ImageMetaInformation, ModelReleasedStatus} import no.ndla.imageapi.model.{InvalidUrlException, api, domain} import no.ndla.imageapi.{TestEnvironment, UnitSuite} import org.mockito.Mockito.when @@ -85,7 +85,7 @@ class ReadServiceTest extends UnitSuite with TestEnvironment { new ImageFileData( fileName = "Elg.jpg", size = 2865539, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", diff --git a/image-api/src/test/scala/no/ndla/imageapi/service/ValidationServiceTest.scala b/image-api/src/test/scala/no/ndla/imageapi/service/ValidationServiceTest.scala index 93c6fc4ba8..ef309e77d5 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/service/ValidationServiceTest.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/service/ValidationServiceTest.scala @@ -31,7 +31,7 @@ class ValidationServiceTest extends UnitSuite with TestEnvironment { new ImageFileData( fileName = "image.jpg", size = 1024, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", diff --git a/image-api/src/test/scala/no/ndla/imageapi/service/WriteServiceTest.scala b/image-api/src/test/scala/no/ndla/imageapi/service/WriteServiceTest.scala index 4e79d16b27..7881eba67b 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/service/WriteServiceTest.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/service/WriteServiceTest.scala @@ -15,7 +15,7 @@ import no.ndla.common.model.domain.{ContributorType, UploadedFile} import no.ndla.common.model.{NDLADate, api as commonApi, domain as common} import no.ndla.imageapi.model.api.* import no.ndla.imageapi.model.domain -import no.ndla.imageapi.model.domain.{ImageMetaInformation, ImageVariantSize, ModelReleasedStatus} +import no.ndla.imageapi.model.domain.{ImageContentType, ImageMetaInformation, ImageVariantSize, ModelReleasedStatus} import no.ndla.imageapi.{TestEnvironment, UnitSuite} import no.ndla.network.tapir.auth.Permission.IMAGE_API_WRITE import no.ndla.network.tapir.auth.TokenUser @@ -59,7 +59,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { new domain.ImageFileData( fileName = "yolo.jpeg", size = 100, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", @@ -78,7 +78,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { ) override def beforeEach(): Unit = { - when(fileMock1.contentType).thenReturn(Some("image/jpeg")) + when(fileMock1.contentType).thenReturn(Some(ImageContentType.Jpeg)) val imageStream = TestData.ndlaLogoImageStream when(fileMock1.createStream()).thenReturn(imageStream.stream) when(fileMock1.fileSize).thenReturn(imageStream.contentLength) @@ -97,10 +97,11 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { test("uploadImageWithVariants should return Success if file upload succeeds") { when(imageStorage.objectExists(any[String])).thenReturn(false) when(imageStorage.uploadFromStream(any, any, any, any)).thenReturn(Success(newFileName)) + when(fileMock1.contentType).thenReturn(Some(ImageContentType.Jpeg.toString)) val expectedImage = domain.UploadedImage( newFileName, fileMock1.fileSize, - "image/jpeg", + ImageContentType.Jpeg, Some(domain.ImageDimensions(189, 60)), Seq.empty, ) @@ -114,6 +115,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { test("uploadImageWithVariants should return Failure if file upload failed") { when(imageStorage.objectExists(any[String])).thenReturn(false) when(imageStorage.uploadFromStream(any, any, any, any)).thenReturn(Failure(new RuntimeException)) + when(fileMock1.contentType).thenReturn(Some(ImageContentType.Jpeg.toString)) writeService.uploadImageWithVariants(fileMock1).isFailure should be(true) } @@ -124,6 +126,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { when(validationService.validate(any, any)).thenAnswer((i: InvocationOnMock) => { Success(i.getArgument[ImageMetaInformation](0)) }) + when(fileMock1.contentType).thenReturn(Some(ImageContentType.Jpeg.toString)) writeService.storeNewImage(newImageMeta, fileMock1, TokenUser.SystemUser).isFailure should be(true) } @@ -152,6 +155,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { Failure(new RuntimeException) ) when(imageStorage.deleteObject(any)).thenReturn(Success(())) + when(fileMock1.contentType).thenReturn(Some(ImageContentType.Jpeg.toString)) writeService.storeNewImage(newImageMeta, fileMock1, TokenUser.SystemUser).isFailure should be(true) verify(imageIndexService, times(0)).indexDocument(any[ImageMetaInformation]) @@ -168,6 +172,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { when(imageStorage.deleteObject(any)).thenReturn(Success(())) when(imageStorage.deleteObjects(any)).thenReturn(Success(())) when(imageRepository.insert(any)(using any)).thenReturn(Success(domainImageMeta.copy(id = Some(100L)))) + when(fileMock1.contentType).thenReturn(Some(ImageContentType.Jpeg.toString)) val result = writeService.storeNewImage(newImageMeta, fileMock1, TokenUser.SystemUser) result.isFailure should be(true) @@ -185,6 +190,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { when(imageStorage.deleteObject(any)).thenReturn(Success(())) when(imageStorage.deleteObjects(any)).thenReturn(Success(())) when(imageRepository.insert(any)(using any)).thenReturn(Success(afterInsert)) + when(fileMock1.contentType).thenReturn(Some(ImageContentType.Jpeg.toString)) writeService.storeNewImage(newImageMeta, fileMock1, TokenUser.SystemUser).isFailure should be(true) verify(imageRepository, times(1)).insert(any[ImageMetaInformation])(using any[DBSession]) @@ -201,10 +207,11 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { ) when(imageIndexService.indexDocument(any[ImageMetaInformation])).thenAnswer(i => Success(i.getArgument(0))) when(tagIndexService.indexDocument(any[ImageMetaInformation])).thenAnswer(i => Success(i.getArgument(0))) + when(fileMock1.contentType).thenReturn(Some(ImageContentType.Jpeg.toString)) val expectedImageFile = domain.ImageFileData( newFileName, fileMock1.fileSize, - "image/jpeg", + ImageContentType.Jpeg, Some(domain.ImageDimensions(189, 60)), Seq.empty, "en", @@ -336,7 +343,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { val image = domain.ImageFileData( fileName = "yo.jpg", size = 123, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = Some(domain.ImageDimensions(10, 10)), variants = Seq.empty, language = "nb", @@ -366,7 +373,16 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { val imageId = 4444.toLong val domainWithImage = domainImageMeta.copy(images = - Seq(domain.ImageFileData(newFileName, 1024, "image/jpeg", Some(domain.ImageDimensions(189, 60)), Seq.empty, "nb")) + Seq( + domain.ImageFileData( + newFileName, + 1024, + ImageContentType.Jpeg, + Some(domain.ImageDimensions(189, 60)), + Seq.empty, + "nb", + ) + ) ) when(imageRepository.withId(imageId)).thenReturn(Success(Some(domainWithImage))) @@ -475,7 +491,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { val image = domain.ImageFileData( fileName = "apekatt.jpg", size = 100, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", @@ -510,6 +526,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { when(clock.now()).thenReturn(coolDate) when(imageStorage.objectExists(any)).thenReturn(false) when(random.string(any)).thenReturn("randomstring") + when(fileMock1.contentType).thenReturn(Some(ImageContentType.Jpeg.toString)) val expectedResult = dbImage.copy( titles = Seq(domain.ImageTitle("new title", "nb")), @@ -556,7 +573,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { val image = domain.ImageFileData( fileName = "apekatt.jpg", size = 100, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", @@ -587,6 +604,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { when(clock.now()).thenReturn(coolDate) when(imageStorage.objectExists(any)).thenReturn(false) when(random.string(any)).thenReturn("randomstring") + when(fileMock1.contentType).thenReturn(Some(ImageContentType.Jpeg.toString)) val expectedResult = dbImage.copy( titles = Seq(domain.ImageTitle("new title", "nb")), @@ -624,7 +642,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { val image = domain.ImageFileData( fileName = "apekatt.jpg", size = 100, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", @@ -689,7 +707,7 @@ class WriteServiceTest extends UnitSuite with TestEnvironment { val image = domain.ImageFileData( fileName = "apekatt.jpg", size = 100, - contentType = "image/jpeg", + contentType = ImageContentType.Jpeg, dimensions = None, variants = Seq.empty, language = "nb", diff --git a/image-api/src/test/scala/no/ndla/imageapi/service/search/ImageSearchServiceTest.scala b/image-api/src/test/scala/no/ndla/imageapi/service/search/ImageSearchServiceTest.scala index 9cf7fc162e..33437206bc 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/service/search/ImageSearchServiceTest.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/service/search/ImageSearchServiceTest.scala @@ -44,7 +44,7 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit val largeImage: ImageFileData = ImageFileData( "large-full-url", 10000, - "image/jpeg", + ImageContentType.Jpeg, Some(ImageDimensions(width = 1920, height = 1080)), Seq.empty, "und", @@ -52,7 +52,7 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit val smallImage: ImageFileData = ImageFileData( "small-full-url", 100, - "image/jpeg", + ImageContentType.Jpeg, Some(ImageDimensions(width = 640, height = 480)), Seq.empty, "und", @@ -60,7 +60,7 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit val podcastImage: ImageFileData = ImageFileData( "podcast-full-url", 100, - "image/jpeg", + ImageContentType.Jpeg, Some(ImageDimensions(width = 1400, height = 1400)), Seq.empty, "und", @@ -68,7 +68,7 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit val wideImage: ImageFileData = ImageFileData( "wide-full-url", 5000, - "image/jpeg", + ImageContentType.Jpeg, Some(ImageDimensions(width = 3840, height = 2160)), Seq.empty, "und", @@ -76,17 +76,23 @@ class ImageSearchServiceTest extends ElasticsearchIntegrationSuite with UnitSuit val tallImage: ImageFileData = ImageFileData( "tall-full-url", 3000, - "image/jpeg", + ImageContentType.Jpeg, Some(ImageDimensions(width = 1080, height = 1920)), Seq.empty, "und", ) - val pngImage: ImageFileData = - ImageFileData("png-full-url", 2000, "image/png", Some(ImageDimensions(width = 800, height = 600)), Seq.empty, "und") + val pngImage: ImageFileData = ImageFileData( + "png-full-url", + 2000, + ImageContentType.Png, + Some(ImageDimensions(width = 800, height = 600)), + Seq.empty, + "und", + ) val svgImage: ImageFileData = ImageFileData( "svg-full-url", 500, - "image/svg+xml", + ImageContentType.Svg, Some(ImageDimensions(width = 512, height = 512)), Seq.empty, "und", diff --git a/typescript/types-backend/image-api-openapi.ts b/typescript/types-backend/image-api-openapi.ts index b22c6f0162..a35851e143 100644 --- a/typescript/types-backend/image-api-openapi.ts +++ b/typescript/types-backend/image-api-openapi.ts @@ -738,6 +738,8 @@ export type components = { * @description Filter images with height less than or equal to this value. */ heightTo?: number; + /** @description Filter images by content type (e.g., 'image/jpeg', 'image/png'). */ + contentType?: string; }; /** * SearchResultDTO @@ -929,6 +931,8 @@ export interface operations { "height-from"?: number; /** @description Filter images with height less than or equal to this value. */ "height-to"?: number; + /** @description Filter images by content type (e.g., 'image/jpeg', 'image/png'). */ + "content-type"?: string; }; header?: never; path?: never; @@ -1511,6 +1515,8 @@ export interface operations { "height-from"?: number; /** @description Filter images with height less than or equal to this value. */ "height-to"?: number; + /** @description Filter images by content type (e.g., 'image/jpeg', 'image/png'). */ + "content-type"?: string; }; header?: never; path?: never; diff --git a/typescript/types-backend/openapi/image-api.json b/typescript/types-backend/openapi/image-api.json index 10a0deb542..d1fcee5b03 100644 --- a/typescript/types-backend/openapi/image-api.json +++ b/typescript/types-backend/openapi/image-api.json @@ -1 +1 @@ -{"openapi":"3.1.0","info":{"title":"image-api","version":"1.0","description":"Searching and fetching all images used in the NDLA platform.\n\nThe Image API provides an endpoint for searching in and fetching images used in NDLA resources. Meta-data are also searched and returned in the results. Examples of meta-data are title, alt-text, language and license.\nThe API can resize and crop transitions on the returned images to enable use in special contexts, e.g. low bandwidth scenarios","termsOfService":"https://om.ndla.no/tos","contact":{"name":"NDLA","email":"hjelp+api@ndla.no","url":"https://ndla.no"},"license":{"name":"GPL v3.0","url":"https://www.gnu.org/licenses/gpl-3.0.en.html"}},"paths":{"/image-api/v2/images":{"get":{"tags":["images V2"],"summary":"Find images.","description":"Find images in the ndla.no database.","operationId":"getImage-apiV2Images","parameters":[{"name":"query","in":"query","description":"Return only images with titles, alt-texts or tags matching the specified query.","required":false,"schema":{"type":"string"}},{"name":"minimum-size","in":"query","description":"Return only images with full size larger than submitted value in bytes.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"default":"*","type":"string"}},{"name":"fallback","in":"query","description":"Fallback to existing language if language is specified.","required":false,"schema":{"default":false,"type":"boolean"}},{"name":"license","in":"query","description":"Return only images with provided license.","required":false,"schema":{"type":"string"}},{"name":"sort","in":"query","description":"The sorting used on results.\n The following are supported: -relevance, relevance, -title, title, -lastUpdated, lastUpdated, -id, id.\n Default is by -relevance (desc) when query is set, and title (asc) when query is empty.","required":false,"schema":{"type":"string"}},{"name":"page","in":"query","description":"The page number of the search hits to display.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"page-size","in":"query","description":"The number of search hits to display for each page. Defaults to 10 and max is 10000.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"podcast-friendly","in":"query","description":"Filter images that are podcast friendly. Width==heigth and between 1400 and 3000.","required":false,"schema":{"type":"boolean"}},{"name":"search-context","in":"query","description":"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: [0,initial,start,first].\nWhen scrolling, the parameters from the initial search is used, except in the case of 'language'.\nThis value may change between scrolls. Always use the one in the latest scroll result (The context, if unused, dies after 1m).\nIf you are not paginating past 10000 hits, you can ignore this and use 'page' and 'page-size' instead.\n","required":false,"schema":{"type":"string"}},{"name":"model-released","in":"query","description":"Filter whether the image(s) should be model-released or not. Multiple values can be specified in a comma separated list. Possible values include: yes,no,not-applicable,not-set","required":false,"explode":false,"schema":{"type":"array","items":{"type":"string"}}},{"name":"inactive","in":"query","description":"Include inactive images","required":false,"schema":{"type":"boolean"}},{"name":"width-from","in":"query","description":"Filter images with width greater than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"width-to","in":"query","description":"Filter images with width less than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"height-from","in":"query","description":"Filter images with height greater than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"height-to","in":"query","description":"Filter images with height less than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResultDTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":[]}]},"post":{"tags":["images V2"],"summary":"Upload a new image with meta information.","description":"Upload a new image file with meta data.","operationId":"postImage-apiV2Images","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/MetaDataAndFileForm"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV2DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"413":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":["images:write"]}]}},"/image-api/v2/images/tag-search":{"get":{"tags":["images V2"],"summary":"Retrieves a list of all previously used tags in images","description":"Retrieves a list of all previously used tags in images","operationId":"getImage-apiV2ImagesTag-search","parameters":[{"name":"query","in":"query","description":"Return only images with titles, alt-texts or tags matching the specified query.","required":false,"schema":{"type":"string"}},{"name":"page-size","in":"query","description":"The number of search hits to display for each page. Defaults to 10 and max is 10000.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"page","in":"query","description":"The page number of the search hits to display.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"default":"*","type":"string"}},{"name":"sort","in":"query","description":"The sorting used on results.\n The following are supported: -relevance, relevance, -title, title, -lastUpdated, lastUpdated, -id, id.\n Default is by -relevance (desc) when query is set, and title (asc) when query is empty.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TagsSearchResultDTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true}},"/image-api/v2/images/search":{"post":{"tags":["images V2"],"summary":"Find images.","description":"Search for images in the ndla.no database.","operationId":"postImage-apiV2ImagesSearch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchParamsDTO"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResultDTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":[]}]}},"/image-api/v2/images/{image_id}":{"get":{"tags":["images V2"],"summary":"Fetch information for image.","description":"Shows info of the image with submitted id.","operationId":"getImage-apiV2ImagesImage_id","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV2DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":[]}]},"delete":{"tags":["images V2"],"summary":"Deletes the specified images meta data and file","description":"Deletes the specified images meta data and file","operationId":"deleteImage-apiV2ImagesImage_id","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"204":{"description":""},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":["images:write"]}]},"patch":{"tags":["images V2"],"summary":"Update an existing image with meta information.","description":"Updates an existing image with meta data.","operationId":"patchImage-apiV2ImagesImage_id","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UpdateMetaDataAndFileForm"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV2DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":["images:write"]}]}},"/image-api/v2/images/external_id/{external_id}":{"get":{"tags":["images V2"],"summary":"Fetch information for image by external id.","description":"Shows info of the image with submitted external id.","operationId":"getImage-apiV2ImagesExternal_idExternal_id","parameters":[{"name":"external_id","in":"path","description":"External node id of the image that needs to be fetched.","required":true,"schema":{"type":"string"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV2DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":[]}]}},"/image-api/v2/images/{image_id}/language/{language}":{"delete":{"tags":["images V2"],"summary":"Delete language version of image metadata.","description":"Delete language version of image metadata.","operationId":"deleteImage-apiV2ImagesImage_idLanguageLanguage","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"language","in":"path","description":"The ISO 639-1 language code describing language.","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV2DTO"}}}},"204":{"description":""},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":["images:write"]}]}},"/image-api/v3/images":{"get":{"tags":["images V3"],"summary":"Find images.","description":"Find images in the ndla.no database.","operationId":"getImage-apiV3Images","parameters":[{"name":"query","in":"query","description":"Return only images with titles, alt-texts or tags matching the specified query.","required":false,"schema":{"type":"string"}},{"name":"minimum-size","in":"query","description":"Return only images with full size larger than submitted value in bytes.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"default":"*","type":"string"}},{"name":"fallback","in":"query","description":"Fallback to existing language if language is specified.","required":false,"schema":{"default":false,"type":"boolean"}},{"name":"license","in":"query","description":"Return only images with provided license.","required":false,"schema":{"type":"string"}},{"name":"includeCopyrighted","in":"query","description":"Return copyrighted images. May be omitted.","required":false,"deprecated":true,"schema":{"default":false,"type":"boolean"}},{"name":"sort","in":"query","description":"The sorting used on results.\n The following are supported: -relevance, relevance, -title, title, -lastUpdated, lastUpdated, -id, id.\n Default is by -relevance (desc) when query is set, and title (asc) when query is empty.","required":false,"schema":{"type":"string"}},{"name":"page","in":"query","description":"The page number of the search hits to display.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"page-size","in":"query","description":"The number of search hits to display for each page. Defaults to 10 and max is 10000.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"podcast-friendly","in":"query","description":"Filter images that are podcast friendly. Width==heigth and between 1400 and 3000.","required":false,"schema":{"type":"boolean"}},{"name":"search-context","in":"query","description":"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: [0,initial,start,first].\nWhen scrolling, the parameters from the initial search is used, except in the case of 'language'.\nThis value may change between scrolls. Always use the one in the latest scroll result (The context, if unused, dies after 1m).\nIf you are not paginating past 10000 hits, you can ignore this and use 'page' and 'page-size' instead.\n","required":false,"schema":{"type":"string"}},{"name":"model-released","in":"query","description":"Filter whether the image(s) should be model-released or not. Multiple values can be specified in a comma separated list. Possible values include: yes,no,not-applicable,not-set","required":false,"explode":false,"schema":{"type":"array","items":{"type":"string"}}},{"name":"users","in":"query","description":"List of users to filter by.\nThe value to search for is the user-id from Auth0.\nUpdatedBy on article and user in editorial-notes are searched.","required":false,"explode":false,"schema":{"type":"array","items":{"type":"string"}}},{"name":"inactive","in":"query","description":"Include inactive images","required":false,"schema":{"type":"boolean"}},{"name":"width-from","in":"query","description":"Filter images with width greater than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"width-to","in":"query","description":"Filter images with width less than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"height-from","in":"query","description":"Filter images with height greater than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"height-to","in":"query","description":"Filter images with height less than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResultV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":[]}]},"post":{"tags":["images V3"],"summary":"Upload a new image with meta information.","description":"Upload a new image file with meta data.","operationId":"postImage-apiV3Images","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/MetaDataAndFileForm"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":["images:write"]}]}},"/image-api/v3/images/ids":{"get":{"tags":["images V3"],"summary":"Fetch images that matches ids parameter.","description":"Fetch images that matches ids parameter.","operationId":"getImage-apiV3ImagesIds","parameters":[{"name":"ids","in":"query","description":"Return only images that have one of the provided ids. To provide multiple ids, separate by comma (,).","required":false,"explode":false,"schema":{"type":"array","items":{"type":"integer","format":"int64"}}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":[]}]}},"/image-api/v3/images/tag-search":{"get":{"tags":["images V3"],"summary":"Retrieves a list of all previously used tags in images","description":"Retrieves a list of all previously used tags in images","operationId":"getImage-apiV3ImagesTag-search","parameters":[{"name":"query","in":"query","description":"Return only images with titles, alt-texts or tags matching the specified query.","required":false,"schema":{"type":"string"}},{"name":"page-size","in":"query","description":"The number of search hits to display for each page. Defaults to 10 and max is 10000.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"page","in":"query","description":"The page number of the search hits to display.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"default":"*","type":"string"}},{"name":"sort","in":"query","description":"The sorting used on results.\n The following are supported: -relevance, relevance, -title, title, -lastUpdated, lastUpdated, -id, id.\n Default is by -relevance (desc) when query is set, and title (asc) when query is empty.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TagsSearchResultDTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}},"/image-api/v3/images/search":{"post":{"tags":["images V3"],"summary":"Find images.","description":"Search for images in the ndla.no database.","operationId":"postImage-apiV3ImagesSearch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchParamsDTO"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResultV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":[]}]}},"/image-api/v3/images/{image_id}":{"get":{"tags":["images V3"],"summary":"Fetch information for image.","description":"Shows info of the image with submitted id.","operationId":"getImage-apiV3ImagesImage_id","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":[]}]},"delete":{"tags":["images V3"],"summary":"Deletes the specified images meta data and file","description":"Deletes the specified images meta data and file","operationId":"deleteImage-apiV3ImagesImage_id","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"204":{"description":""},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":["images:write"]}]},"patch":{"tags":["images V3"],"summary":"Update an existing image with meta information.","description":"Updates an existing image with meta data.","operationId":"patchImage-apiV3ImagesImage_id","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UpdateMetaDataAndFileForm"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":["images:write"]}]}},"/image-api/v3/images/external_id/{external_id}":{"get":{"tags":["images V3"],"summary":"Fetch information for image by external id.","description":"Shows info of the image with submitted external id.","operationId":"getImage-apiV3ImagesExternal_idExternal_id","parameters":[{"name":"external_id","in":"path","description":"External node id of the image that needs to be fetched.","required":true,"schema":{"type":"string"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":[]}]}},"/image-api/v3/images/{image_id}/language/{language}":{"delete":{"tags":["images V3"],"summary":"Delete language version of image metadata.","description":"Delete language version of image metadata.","operationId":"deleteImage-apiV3ImagesImage_idLanguageLanguage","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"language","in":"path","description":"The ISO 639-1 language code describing language.","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"204":{"description":""},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":["images:write"]}]}},"/image-api/v3/images/{image_id}/copy":{"post":{"tags":["images V3"],"summary":"Copy image meta data with a new image file","description":"Copy image meta data with a new image file","operationId":"postImage-apiV3ImagesImage_idCopy","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/CopyMetaDataAndFileForm"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":["images:write"]}]}},"/image-api/raw/id/{image_id}":{"get":{"tags":["raw"],"summary":"Fetch an image with options to resize and crop","description":"Fetches a image with options to resize and crop","operationId":"getImage-apiRawIdImage_id","parameters":[{"name":"image_id","in":"path","description":"The ID of the image","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"app-key","in":"header","description":"Your app-key. May be omitted to access api anonymously, but rate limiting may apply on anonymous access.","required":false,"schema":{"type":"string"}},{"name":"width","in":"query","description":"The target width to resize the image (the unit is pixles). Image proportions are kept intact","required":false,"schema":{"type":"number","format":"double"}},{"name":"height","in":"query","description":"The target height to resize the image (the unit is pixles). Image proportions are kept intact","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropStartX","in":"query","description":"The first image coordinate X, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop start position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropStartY","in":"query","description":"The first image coordinate Y, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop start position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropEndX","in":"query","description":"The end image coordinate X, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop end position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropEndY","in":"query","description":"The end image coordinate Y, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop end position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropUnit","in":"query","description":"The unit of the crop parameters. Can be either 'percent' or 'pixel'. If omitted the unit is assumed to be 'percent'","required":false,"schema":{"type":"string"}},{"name":"focalX","in":"query","description":"The end image coordinate X, in percent (0 to 100), specifying the focal point. If used the other focal point parameter, width and/or height, must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"focalY","in":"query","description":"The end image coordinate Y, in percent (0 to 100), specifying the focal point. If used the other focal point parameter, width and/or height, must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"ratio","in":"query","description":"The wanted aspect ratio, defined as width/height. To be used together with the focal parameters. If used the width and height is ignored and derived from the aspect ratio instead.","required":false,"schema":{"type":"number","format":"double"}},{"name":"language","in":"query","description":"The wanted aspect ratio, defined as width/height. To be used together with the focal parameters. If used the width and height is ignored and derived from the aspect ratio instead.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}},"/image-api/raw/{image_name}":{"get":{"tags":["raw"],"summary":"Fetch an image with options to resize and crop","description":"Fetches a image with options to resize and crop","operationId":"getImage-apiRawImage_name","parameters":[{"name":"image_name","in":"path","description":"The name of the image","required":true,"schema":{"type":"string"}},{"name":"app-key","in":"header","description":"Your app-key. May be omitted to access api anonymously, but rate limiting may apply on anonymous access.","required":false,"schema":{"type":"string"}},{"name":"width","in":"query","description":"The target width to resize the image (the unit is pixles). Image proportions are kept intact","required":false,"schema":{"type":"number","format":"double"}},{"name":"height","in":"query","description":"The target height to resize the image (the unit is pixles). Image proportions are kept intact","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropStartX","in":"query","description":"The first image coordinate X, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop start position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropStartY","in":"query","description":"The first image coordinate Y, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop start position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropEndX","in":"query","description":"The end image coordinate X, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop end position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropEndY","in":"query","description":"The end image coordinate Y, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop end position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropUnit","in":"query","description":"The unit of the crop parameters. Can be either 'percent' or 'pixel'. If omitted the unit is assumed to be 'percent'","required":false,"schema":{"type":"string"}},{"name":"focalX","in":"query","description":"The end image coordinate X, in percent (0 to 100), specifying the focal point. If used the other focal point parameter, width and/or height, must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"focalY","in":"query","description":"The end image coordinate Y, in percent (0 to 100), specifying the focal point. If used the other focal point parameter, width and/or height, must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"ratio","in":"query","description":"The wanted aspect ratio, defined as width/height. To be used together with the focal parameters. If used the width and height is ignored and derived from the aspect ratio instead.","required":false,"schema":{"type":"number","format":"double"}},{"name":"language","in":"query","description":"The wanted aspect ratio, defined as width/height. To be used together with the focal parameters. If used the width and height is ignored and derived from the aspect ratio instead.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}}},"components":{"schemas":{"AllErrors":{"title":"AllErrors","oneOf":[{"$ref":"#/components/schemas/ErrorBody"},{"$ref":"#/components/schemas/NotFoundWithSupportedLanguages"},{"$ref":"#/components/schemas/ValidationErrorBody"}]},"AuthorDTO":{"title":"AuthorDTO","description":"Information about an author","type":"object","required":["type","name"],"properties":{"type":{"$ref":"#/components/schemas/ContributorType"},"name":{"description":"The name of the of the author","type":"string"}}},"ContributorType":{"title":"ContributorType","description":"The description of the author. Eg. Photographer or Supplier","type":"string","enum":["artist","cowriter","compiler","composer","correction","director","distributor","editorial","facilitator","idea","illustrator","linguistic","originator","photographer","processor","publisher","reader","rightsholder","scriptwriter","supplier","translator","writer"]},"CopyMetaDataAndFileForm":{"title":"CopyMetaDataAndFileForm","type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary"}}},"CopyrightDTO":{"title":"CopyrightDTO","description":"Describes the copyright information for the image","type":"object","required":["license","creators","processors","rightsholders","processed"],"properties":{"license":{"$ref":"#/components/schemas/LicenseDTO"},"origin":{"description":"Reference to where the article is procured","type":"string"},"creators":{"description":"List of creators","type":"array","items":{"$ref":"#/components/schemas/AuthorDTO"}},"processors":{"description":"List of processors","type":"array","items":{"$ref":"#/components/schemas/AuthorDTO"}},"rightsholders":{"description":"List of rightsholders","type":"array","items":{"$ref":"#/components/schemas/AuthorDTO"}},"validFrom":{"description":"Date from which the copyright is valid","type":"string"},"validTo":{"description":"Date to which the copyright is valid","type":"string"},"processed":{"description":"Whether or not the content has been processed","type":"boolean"}}},"EditorNoteDTO":{"title":"EditorNoteDTO","description":"Note about a change that happened to the image","type":"object","required":["timestamp","updatedBy","note"],"properties":{"timestamp":{"description":"Timestamp of the change","type":"string"},"updatedBy":{"description":"Who triggered the change","type":"string"},"note":{"description":"Editorial note","type":"string"}}},"ErrorBody":{"title":"ErrorBody","description":"Information about an error","type":"object","required":["code","description","occurredAt","statusCode"],"properties":{"code":{"description":"Code stating the type of error","type":"string"},"description":{"description":"Description of the error","type":"string"},"occurredAt":{"description":"When the error occurred","type":"string"},"statusCode":{"description":"Numeric http status code","type":"integer","format":"int32"}}},"ImageAltTextDTO":{"title":"ImageAltTextDTO","type":"object","required":["alttext","language"],"properties":{"alttext":{"description":"The alternative text for the image","type":"string"},"language":{"description":"ISO 639-1 code that represents the language used in the alternative text","type":"string"}}},"ImageCaptionDTO":{"title":"ImageCaptionDTO","type":"object","required":["caption","language"],"properties":{"caption":{"description":"The caption for the image","type":"string"},"language":{"description":"ISO 639-1 code that represents the language used in the caption","type":"string"}}},"ImageDimensionsDTO":{"title":"ImageDimensionsDTO","description":"Dimensions of the image","type":"object","required":["width","height"],"properties":{"width":{"description":"The width of the image in pixels","type":"integer","format":"int32"},"height":{"description":"The height of the image in pixels","type":"integer","format":"int32"}}},"ImageFileDTO":{"title":"ImageFileDTO","description":"Describes the image file","type":"object","required":["fileName","size","contentType","imageUrl","variants","language"],"properties":{"fileName":{"description":"File name pointing to image file","type":"string"},"size":{"description":"The size of the image in bytes","type":"integer","format":"int64"},"contentType":{"description":"The mimetype of the image","type":"string"},"imageUrl":{"description":"The full url to where the image can be downloaded","type":"string"},"dimensions":{"$ref":"#/components/schemas/ImageDimensionsDTO"},"variants":{"description":"Size variants of the image","type":"array","items":{"$ref":"#/components/schemas/ImageVariantDTO"}},"language":{"description":"ISO 639-1 code that represents the language used in the caption","type":"string"}}},"ImageMetaInformationV2DTO":{"title":"ImageMetaInformationV2DTO","description":"Meta information for the image","type":"object","required":["id","metaUrl","title","alttext","imageUrl","size","contentType","copyright","tags","caption","supportedLanguages","created","createdBy","modelRelease"],"properties":{"id":{"description":"The unique id of the image","type":"string"},"metaUrl":{"description":"The url to where this information can be found","type":"string"},"title":{"$ref":"#/components/schemas/ImageTitleDTO","description":"The title for the image"},"alttext":{"$ref":"#/components/schemas/ImageAltTextDTO","description":"Alternative text for the image"},"imageUrl":{"description":"The full url to where the image can be downloaded","type":"string"},"size":{"description":"The size of the image in bytes","type":"integer","format":"int64"},"contentType":{"description":"The mimetype of the image","type":"string"},"copyright":{"$ref":"#/components/schemas/CopyrightDTO"},"tags":{"$ref":"#/components/schemas/ImageTagDTO"},"caption":{"$ref":"#/components/schemas/ImageCaptionDTO","description":"Searchable caption for the image"},"supportedLanguages":{"description":"Supported languages for the image title, alt-text, tags and caption.","type":"array","items":{"type":"string"}},"created":{"description":"Describes when the image was created","type":"string"},"createdBy":{"description":"Describes who created the image","type":"string"},"modelRelease":{"description":"Describes if the model has released use of the image","type":"string"},"editorNotes":{"description":"Describes the changes made to the image, only visible to editors","type":"array","items":{"$ref":"#/components/schemas/EditorNoteDTO"}},"imageDimensions":{"$ref":"#/components/schemas/ImageDimensionsDTO"}}},"ImageMetaInformationV3DTO":{"title":"ImageMetaInformationV3DTO","description":"Meta information for the image","type":"object","required":["id","metaUrl","title","alttext","copyright","tags","caption","supportedLanguages","created","createdBy","modelRelease","image","inactive"],"properties":{"id":{"description":"The unique id of the image","type":"string"},"metaUrl":{"description":"The url to where this information can be found","type":"string"},"title":{"$ref":"#/components/schemas/ImageTitleDTO","description":"The title for the image"},"alttext":{"$ref":"#/components/schemas/ImageAltTextDTO","description":"Alternative text for the image"},"copyright":{"$ref":"#/components/schemas/CopyrightDTO"},"tags":{"$ref":"#/components/schemas/ImageTagDTO"},"caption":{"$ref":"#/components/schemas/ImageCaptionDTO","description":"Searchable caption for the image"},"supportedLanguages":{"description":"Supported languages for the image title, alt-text, tags and caption.","type":"array","items":{"type":"string"}},"created":{"description":"Describes when the image was created","type":"string"},"createdBy":{"description":"Describes who created the image","type":"string"},"modelRelease":{"description":"Describes if the model has released use of the image","type":"string"},"editorNotes":{"description":"Describes the changes made to the image, only visible to editors","type":"array","items":{"$ref":"#/components/schemas/EditorNoteDTO"}},"image":{"$ref":"#/components/schemas/ImageFileDTO"},"inactive":{"description":"Describes if the image is inactive or not","type":"boolean"}}},"ImageMetaSummaryDTO":{"title":"ImageMetaSummaryDTO","description":"Summary of meta information for an image","type":"object","required":["id","title","contributors","altText","caption","previewUrl","metaUrl","license","supportedLanguages","lastUpdated","fileSize","contentType","inactive"],"properties":{"id":{"description":"The unique id of the image","type":"string"},"title":{"$ref":"#/components/schemas/ImageTitleDTO","description":"The title for this image"},"contributors":{"description":"The copyright authors for this image","type":"array","items":{"type":"string"}},"altText":{"$ref":"#/components/schemas/ImageAltTextDTO","description":"The alt text for this image"},"caption":{"$ref":"#/components/schemas/ImageCaptionDTO","description":"The caption for this image"},"previewUrl":{"description":"The full url to where a preview of the image can be downloaded","type":"string"},"metaUrl":{"description":"The full url to where the complete metainformation about the image can be found","type":"string"},"license":{"description":"Describes the license of the image","type":"string"},"supportedLanguages":{"description":"List of supported languages in priority","type":"array","items":{"type":"string"}},"modelRelease":{"description":"Describes if the model has released use of the image","type":"string"},"editorNotes":{"description":"Describes the changes made to the image, only visible to editors","type":"array","items":{"type":"string"}},"lastUpdated":{"description":"The time and date of last update","type":"string"},"fileSize":{"description":"The size of the image in bytes","type":"integer","format":"int64"},"contentType":{"description":"The mimetype of the image","type":"string"},"imageDimensions":{"$ref":"#/components/schemas/ImageDimensionsDTO"},"inactive":{"description":"Whether the image is inactive or not","type":"boolean"}}},"ImageTagDTO":{"title":"ImageTagDTO","description":"Searchable tags for the image","type":"object","required":["tags","language"],"properties":{"tags":{"description":"The searchable tag.","type":"array","items":{"type":"string"}},"language":{"description":"ISO 639-1 code that represents the language used in tag","type":"string"}}},"ImageTitleDTO":{"title":"ImageTitleDTO","type":"object","required":["title","language"],"properties":{"title":{"description":"The freetext title of the image","type":"string"},"language":{"description":"ISO 639-1 code that represents the language used in title","type":"string"}}},"ImageVariantDTO":{"title":"ImageVariantDTO","type":"object","required":["size","variantUrl"],"properties":{"size":{"$ref":"#/components/schemas/ImageVariantSize"},"variantUrl":{"description":"The full URL to where the image variant can be downloaded","type":"string"}}},"ImageVariantSize":{"title":"ImageVariantSize","description":"The named size of this image variant","type":"string","enum":["icon","xsmall","small","medium","large","xlarge","xxlarge"]},"LicenseDTO":{"title":"LicenseDTO","description":"Describes the license of the article","type":"object","required":["license"],"properties":{"license":{"description":"The name of the license","type":"string"},"description":{"description":"Description of the license","type":"string"},"url":{"description":"Url to where the license can be found","type":"string"}}},"MetaDataAndFileForm":{"title":"MetaDataAndFileForm","type":"object","required":["metadata","file"],"properties":{"metadata":{"$ref":"#/components/schemas/NewImageMetaInformationV2DTO"},"file":{"type":"string","format":"binary"}}},"NewImageMetaInformationV2DTO":{"title":"NewImageMetaInformationV2DTO","description":"Meta information for the image","type":"object","required":["title","copyright","tags","caption","language"],"properties":{"title":{"description":"Title for the image","type":"string"},"alttext":{"description":"Alternative text for the image","type":"string"},"copyright":{"$ref":"#/components/schemas/CopyrightDTO"},"tags":{"description":"Searchable tags for the image","type":"array","items":{"type":"string"}},"caption":{"description":"Caption for the image","type":"string"},"language":{"description":"ISO 639-1 code that represents the language used in the caption","type":"string"},"modelReleased":{"description":"Describes if the model has released use of the image, allowed values are 'not-set', 'yes', 'no', and 'not-applicable', defaults to 'no'","type":"string"}}},"NotFoundWithSupportedLanguages":{"title":"NotFoundWithSupportedLanguages","description":"Information about an error","type":"object","required":["code","description","occurredAt","statusCode"],"properties":{"code":{"description":"Code stating the type of error","type":"string"},"description":{"description":"Description of the error","type":"string"},"occurredAt":{"description":"When the error occurred","type":"string"},"supportedLanguages":{"description":"List of supported languages","type":"array","items":{"type":"string"}},"statusCode":{"description":"Numeric http status code","type":"integer","format":"int32"}}},"SearchParamsDTO":{"title":"SearchParamsDTO","description":"The search parameters","type":"object","properties":{"query":{"description":"Return only images with titles, alt-texts or tags matching the specified query.","type":"string"},"license":{"description":"Return only images with provided license.","type":"string"},"language":{"description":"The ISO 639-1 language code describing language used in query-params","type":"string"},"fallback":{"description":"Fallback to existing language if language is specified.","type":"boolean"},"minimumSize":{"description":"Return only images with full size larger than submitted value in bytes.","type":"integer","format":"int32"},"includeCopyrighted":{"description":"Return copyrighted images. May be omitted.","deprecated":true,"type":"boolean"},"sort":{"$ref":"#/components/schemas/Sort"},"page":{"description":"The page number of the search hits to display.","type":"integer","format":"int32"},"pageSize":{"description":"The number of search hits to display for each page.","type":"integer","format":"int32"},"podcastFriendly":{"description":"Only show podcast friendly images. Same width and height, and between 1400 and 3000 pixels.","type":"boolean"},"scrollId":{"description":"A search context retrieved from the response header of a previous search.","type":"string"},"inactive":{"description":"Include inactive images","type":"boolean"},"modelReleased":{"description":"Return only images with one of the provided values for modelReleased.","type":"array","items":{"type":"string"}},"users":{"description":"Filter editors of the image(s). Multiple values can be specified in a comma separated list.","type":"array","items":{"type":"string"}},"widthFrom":{"description":"Filter images with width greater than or equal to this value.","type":"integer","format":"int32"},"widthTo":{"description":"Filter images with width less than or equal to this value.","type":"integer","format":"int32"},"heightFrom":{"description":"Filter images with height greater than or equal to this value.","type":"integer","format":"int32"},"heightTo":{"description":"Filter images with height less than or equal to this value.","type":"integer","format":"int32"}}},"SearchResultDTO":{"title":"SearchResultDTO","description":"Information about search-results","type":"object","required":["totalCount","pageSize","language","results"],"properties":{"totalCount":{"description":"The total number of images matching this query","type":"integer","format":"int64"},"page":{"description":"For which page results are shown from","type":"integer","format":"int32"},"pageSize":{"description":"The number of results per page","type":"integer","format":"int32"},"language":{"description":"The chosen search language","type":"string"},"results":{"description":"The search results","type":"array","items":{"$ref":"#/components/schemas/ImageMetaSummaryDTO"}}}},"SearchResultV3DTO":{"title":"SearchResultV3DTO","description":"Information about search-results","type":"object","required":["totalCount","pageSize","language","results"],"properties":{"totalCount":{"description":"The total number of images matching this query","type":"integer","format":"int64"},"page":{"description":"For which page results are shown from","type":"integer","format":"int32"},"pageSize":{"description":"The number of results per page","type":"integer","format":"int32"},"language":{"description":"The chosen search language","type":"string"},"results":{"description":"The search results","type":"array","items":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"Sort":{"title":"Sort","description":"The sorting used on results. The following are supported: relevance, -relevance, title, -title, lastUpdated, -lastUpdated, id, -id. Default is by -relevance (desc) when query is set, and title (asc) when query is empty.","type":"string","enum":["-relevance","relevance","-title","title","-lastUpdated","lastUpdated","-id","id"]},"TagsSearchResultDTO":{"title":"TagsSearchResultDTO","description":"Information about tags-search-results","type":"object","required":["totalCount","page","pageSize","language","results"],"properties":{"totalCount":{"description":"The total number of tags matching this query","type":"integer","format":"int64"},"page":{"description":"For which page results are shown from","type":"integer","format":"int32"},"pageSize":{"description":"The number of results per page","type":"integer","format":"int32"},"language":{"description":"The chosen search language","type":"string"},"results":{"description":"The search results","type":"array","items":{"type":"string"}}}},"UpdateImageMetaInformationDTO":{"title":"UpdateImageMetaInformationDTO","description":"Meta information for the image","type":"object","required":["language"],"properties":{"language":{"description":"ISO 639-1 code that represents the language","type":"string"},"title":{"description":"Title for the image","type":"string"},"alttext":{"description":"Alternative text for the image","type":["string","null"]},"copyright":{"$ref":"#/components/schemas/CopyrightDTO"},"tags":{"description":"Searchable tags for the image","type":"array","items":{"type":"string"}},"caption":{"description":"Caption for the image","type":"string"},"modelReleased":{"description":"Describes if the model has released use of the image","type":"string"},"inactive":{"description":"Whether the image is inactive","type":"boolean"}}},"UpdateMetaDataAndFileForm":{"title":"UpdateMetaDataAndFileForm","type":"object","required":["metadata"],"properties":{"metadata":{"$ref":"#/components/schemas/UpdateImageMetaInformationDTO"},"file":{"type":"string","format":"binary"}}},"ValidationErrorBody":{"title":"ValidationErrorBody","description":"Information about an error","type":"object","required":["code","description","occurredAt","statusCode"],"properties":{"code":{"description":"Code stating the type of error","type":"string"},"description":{"description":"Description of the error","type":"string"},"occurredAt":{"description":"When the error occurred","type":"string"},"messages":{"description":"List of validation messages","type":"array","items":{"$ref":"#/components/schemas/ValidationMessage"}},"statusCode":{"description":"Numeric http status code","type":"integer","format":"int32"}}},"ValidationMessage":{"title":"ValidationMessage","description":"A message describing a validation error on a specific field","type":"object","required":["field","message"],"properties":{"field":{"description":"The field the error occured in","type":"string"},"message":{"description":"The validation message","type":"string"}}}},"securitySchemes":{"oauth2":{"type":"oauth2","flows":{"implicit":{"authorizationUrl":"https://ndla-test.eu.auth0.com/authorize","scopes":{"concept:admin":"concept:admin","concept:write":"concept:write"}}}}}}} \ No newline at end of file +{"openapi":"3.1.0","info":{"title":"image-api","version":"1.0","description":"Searching and fetching all images used in the NDLA platform.\n\nThe Image API provides an endpoint for searching in and fetching images used in NDLA resources. Meta-data are also searched and returned in the results. Examples of meta-data are title, alt-text, language and license.\nThe API can resize and crop transitions on the returned images to enable use in special contexts, e.g. low bandwidth scenarios","termsOfService":"https://om.ndla.no/tos","contact":{"name":"NDLA","email":"hjelp+api@ndla.no","url":"https://ndla.no"},"license":{"name":"GPL v3.0","url":"https://www.gnu.org/licenses/gpl-3.0.en.html"}},"paths":{"/image-api/v2/images":{"get":{"tags":["images V2"],"summary":"Find images.","description":"Find images in the ndla.no database.","operationId":"getImage-apiV2Images","parameters":[{"name":"query","in":"query","description":"Return only images with titles, alt-texts or tags matching the specified query.","required":false,"schema":{"type":"string"}},{"name":"minimum-size","in":"query","description":"Return only images with full size larger than submitted value in bytes.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"default":"*","type":"string"}},{"name":"fallback","in":"query","description":"Fallback to existing language if language is specified.","required":false,"schema":{"default":false,"type":"boolean"}},{"name":"license","in":"query","description":"Return only images with provided license.","required":false,"schema":{"type":"string"}},{"name":"sort","in":"query","description":"The sorting used on results.\n The following are supported: -relevance, relevance, -title, title, -lastUpdated, lastUpdated, -id, id.\n Default is by -relevance (desc) when query is set, and title (asc) when query is empty.","required":false,"schema":{"type":"string"}},{"name":"page","in":"query","description":"The page number of the search hits to display.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"page-size","in":"query","description":"The number of search hits to display for each page. Defaults to 10 and max is 10000.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"podcast-friendly","in":"query","description":"Filter images that are podcast friendly. Width==heigth and between 1400 and 3000.","required":false,"schema":{"type":"boolean"}},{"name":"search-context","in":"query","description":"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: [0,initial,start,first].\nWhen scrolling, the parameters from the initial search is used, except in the case of 'language'.\nThis value may change between scrolls. Always use the one in the latest scroll result (The context, if unused, dies after 1m).\nIf you are not paginating past 10000 hits, you can ignore this and use 'page' and 'page-size' instead.\n","required":false,"schema":{"type":"string"}},{"name":"model-released","in":"query","description":"Filter whether the image(s) should be model-released or not. Multiple values can be specified in a comma separated list. Possible values include: yes,no,not-applicable,not-set","required":false,"explode":false,"schema":{"type":"array","items":{"type":"string"}}},{"name":"inactive","in":"query","description":"Include inactive images","required":false,"schema":{"type":"boolean"}},{"name":"width-from","in":"query","description":"Filter images with width greater than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"width-to","in":"query","description":"Filter images with width less than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"height-from","in":"query","description":"Filter images with height greater than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"height-to","in":"query","description":"Filter images with height less than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"content-type","in":"query","description":"Filter images by content type (e.g., 'image/jpeg', 'image/png').","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResultDTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":[]}]},"post":{"tags":["images V2"],"summary":"Upload a new image with meta information.","description":"Upload a new image file with meta data.","operationId":"postImage-apiV2Images","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/MetaDataAndFileForm"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV2DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"413":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":["images:write"]}]}},"/image-api/v2/images/tag-search":{"get":{"tags":["images V2"],"summary":"Retrieves a list of all previously used tags in images","description":"Retrieves a list of all previously used tags in images","operationId":"getImage-apiV2ImagesTag-search","parameters":[{"name":"query","in":"query","description":"Return only images with titles, alt-texts or tags matching the specified query.","required":false,"schema":{"type":"string"}},{"name":"page-size","in":"query","description":"The number of search hits to display for each page. Defaults to 10 and max is 10000.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"page","in":"query","description":"The page number of the search hits to display.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"default":"*","type":"string"}},{"name":"sort","in":"query","description":"The sorting used on results.\n The following are supported: -relevance, relevance, -title, title, -lastUpdated, lastUpdated, -id, id.\n Default is by -relevance (desc) when query is set, and title (asc) when query is empty.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TagsSearchResultDTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true}},"/image-api/v2/images/search":{"post":{"tags":["images V2"],"summary":"Find images.","description":"Search for images in the ndla.no database.","operationId":"postImage-apiV2ImagesSearch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchParamsDTO"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResultDTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":[]}]}},"/image-api/v2/images/{image_id}":{"get":{"tags":["images V2"],"summary":"Fetch information for image.","description":"Shows info of the image with submitted id.","operationId":"getImage-apiV2ImagesImage_id","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV2DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":[]}]},"delete":{"tags":["images V2"],"summary":"Deletes the specified images meta data and file","description":"Deletes the specified images meta data and file","operationId":"deleteImage-apiV2ImagesImage_id","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"204":{"description":""},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":["images:write"]}]},"patch":{"tags":["images V2"],"summary":"Update an existing image with meta information.","description":"Updates an existing image with meta data.","operationId":"patchImage-apiV2ImagesImage_id","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UpdateMetaDataAndFileForm"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV2DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":["images:write"]}]}},"/image-api/v2/images/external_id/{external_id}":{"get":{"tags":["images V2"],"summary":"Fetch information for image by external id.","description":"Shows info of the image with submitted external id.","operationId":"getImage-apiV2ImagesExternal_idExternal_id","parameters":[{"name":"external_id","in":"path","description":"External node id of the image that needs to be fetched.","required":true,"schema":{"type":"string"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV2DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":[]}]}},"/image-api/v2/images/{image_id}/language/{language}":{"delete":{"tags":["images V2"],"summary":"Delete language version of image metadata.","description":"Delete language version of image metadata.","operationId":"deleteImage-apiV2ImagesImage_idLanguageLanguage","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"language","in":"path","description":"The ISO 639-1 language code describing language.","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV2DTO"}}}},"204":{"description":""},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"deprecated":true,"security":[{},{"oauth2":["images:write"]}]}},"/image-api/v3/images":{"get":{"tags":["images V3"],"summary":"Find images.","description":"Find images in the ndla.no database.","operationId":"getImage-apiV3Images","parameters":[{"name":"query","in":"query","description":"Return only images with titles, alt-texts or tags matching the specified query.","required":false,"schema":{"type":"string"}},{"name":"minimum-size","in":"query","description":"Return only images with full size larger than submitted value in bytes.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"default":"*","type":"string"}},{"name":"fallback","in":"query","description":"Fallback to existing language if language is specified.","required":false,"schema":{"default":false,"type":"boolean"}},{"name":"license","in":"query","description":"Return only images with provided license.","required":false,"schema":{"type":"string"}},{"name":"includeCopyrighted","in":"query","description":"Return copyrighted images. May be omitted.","required":false,"deprecated":true,"schema":{"default":false,"type":"boolean"}},{"name":"sort","in":"query","description":"The sorting used on results.\n The following are supported: -relevance, relevance, -title, title, -lastUpdated, lastUpdated, -id, id.\n Default is by -relevance (desc) when query is set, and title (asc) when query is empty.","required":false,"schema":{"type":"string"}},{"name":"page","in":"query","description":"The page number of the search hits to display.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"page-size","in":"query","description":"The number of search hits to display for each page. Defaults to 10 and max is 10000.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"podcast-friendly","in":"query","description":"Filter images that are podcast friendly. Width==heigth and between 1400 and 3000.","required":false,"schema":{"type":"boolean"}},{"name":"search-context","in":"query","description":"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: [0,initial,start,first].\nWhen scrolling, the parameters from the initial search is used, except in the case of 'language'.\nThis value may change between scrolls. Always use the one in the latest scroll result (The context, if unused, dies after 1m).\nIf you are not paginating past 10000 hits, you can ignore this and use 'page' and 'page-size' instead.\n","required":false,"schema":{"type":"string"}},{"name":"model-released","in":"query","description":"Filter whether the image(s) should be model-released or not. Multiple values can be specified in a comma separated list. Possible values include: yes,no,not-applicable,not-set","required":false,"explode":false,"schema":{"type":"array","items":{"type":"string"}}},{"name":"users","in":"query","description":"List of users to filter by.\nThe value to search for is the user-id from Auth0.\nUpdatedBy on article and user in editorial-notes are searched.","required":false,"explode":false,"schema":{"type":"array","items":{"type":"string"}}},{"name":"inactive","in":"query","description":"Include inactive images","required":false,"schema":{"type":"boolean"}},{"name":"width-from","in":"query","description":"Filter images with width greater than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"width-to","in":"query","description":"Filter images with width less than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"height-from","in":"query","description":"Filter images with height greater than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"height-to","in":"query","description":"Filter images with height less than or equal to this value.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"content-type","in":"query","description":"Filter images by content type (e.g., 'image/jpeg', 'image/png').","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResultV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":[]}]},"post":{"tags":["images V3"],"summary":"Upload a new image with meta information.","description":"Upload a new image file with meta data.","operationId":"postImage-apiV3Images","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/MetaDataAndFileForm"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":["images:write"]}]}},"/image-api/v3/images/ids":{"get":{"tags":["images V3"],"summary":"Fetch images that matches ids parameter.","description":"Fetch images that matches ids parameter.","operationId":"getImage-apiV3ImagesIds","parameters":[{"name":"ids","in":"query","description":"Return only images that have one of the provided ids. To provide multiple ids, separate by comma (,).","required":false,"explode":false,"schema":{"type":"array","items":{"type":"integer","format":"int64"}}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":[]}]}},"/image-api/v3/images/tag-search":{"get":{"tags":["images V3"],"summary":"Retrieves a list of all previously used tags in images","description":"Retrieves a list of all previously used tags in images","operationId":"getImage-apiV3ImagesTag-search","parameters":[{"name":"query","in":"query","description":"Return only images with titles, alt-texts or tags matching the specified query.","required":false,"schema":{"type":"string"}},{"name":"page-size","in":"query","description":"The number of search hits to display for each page. Defaults to 10 and max is 10000.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"page","in":"query","description":"The page number of the search hits to display.","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"default":"*","type":"string"}},{"name":"sort","in":"query","description":"The sorting used on results.\n The following are supported: -relevance, relevance, -title, title, -lastUpdated, lastUpdated, -id, id.\n Default is by -relevance (desc) when query is set, and title (asc) when query is empty.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TagsSearchResultDTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}},"/image-api/v3/images/search":{"post":{"tags":["images V3"],"summary":"Find images.","description":"Search for images in the ndla.no database.","operationId":"postImage-apiV3ImagesSearch","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchParamsDTO"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SearchResultV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":[]}]}},"/image-api/v3/images/{image_id}":{"get":{"tags":["images V3"],"summary":"Fetch information for image.","description":"Shows info of the image with submitted id.","operationId":"getImage-apiV3ImagesImage_id","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":[]}]},"delete":{"tags":["images V3"],"summary":"Deletes the specified images meta data and file","description":"Deletes the specified images meta data and file","operationId":"deleteImage-apiV3ImagesImage_id","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"204":{"description":""},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":["images:write"]}]},"patch":{"tags":["images V3"],"summary":"Update an existing image with meta information.","description":"Updates an existing image with meta data.","operationId":"patchImage-apiV3ImagesImage_id","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UpdateMetaDataAndFileForm"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":["images:write"]}]}},"/image-api/v3/images/external_id/{external_id}":{"get":{"tags":["images V3"],"summary":"Fetch information for image by external id.","description":"Shows info of the image with submitted external id.","operationId":"getImage-apiV3ImagesExternal_idExternal_id","parameters":[{"name":"external_id","in":"path","description":"External node id of the image that needs to be fetched.","required":true,"schema":{"type":"string"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":[]}]}},"/image-api/v3/images/{image_id}/language/{language}":{"delete":{"tags":["images V3"],"summary":"Delete language version of image metadata.","description":"Delete language version of image metadata.","operationId":"deleteImage-apiV3ImagesImage_idLanguageLanguage","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"language","in":"path","description":"The ISO 639-1 language code describing language.","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"204":{"description":""},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"401":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"403":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":["images:write"]}]}},"/image-api/v3/images/{image_id}/copy":{"post":{"tags":["images V3"],"summary":"Copy image meta data with a new image file","description":"Copy image meta data with a new image file","operationId":"postImage-apiV3ImagesImage_idCopy","parameters":[{"name":"image_id","in":"path","description":"Image_id of the image that needs to be fetched.","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"language","in":"query","description":"The ISO 639-1 language code describing language.","required":false,"schema":{"type":"string"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/CopyMetaDataAndFileForm"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}},"security":[{},{"oauth2":["images:write"]}]}},"/image-api/raw/id/{image_id}":{"get":{"tags":["raw"],"summary":"Fetch an image with options to resize and crop","description":"Fetches a image with options to resize and crop","operationId":"getImage-apiRawIdImage_id","parameters":[{"name":"image_id","in":"path","description":"The ID of the image","required":true,"schema":{"type":"integer","format":"int64"}},{"name":"app-key","in":"header","description":"Your app-key. May be omitted to access api anonymously, but rate limiting may apply on anonymous access.","required":false,"schema":{"type":"string"}},{"name":"width","in":"query","description":"The target width to resize the image (the unit is pixles). Image proportions are kept intact","required":false,"schema":{"type":"number","format":"double"}},{"name":"height","in":"query","description":"The target height to resize the image (the unit is pixles). Image proportions are kept intact","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropStartX","in":"query","description":"The first image coordinate X, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop start position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropStartY","in":"query","description":"The first image coordinate Y, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop start position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropEndX","in":"query","description":"The end image coordinate X, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop end position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropEndY","in":"query","description":"The end image coordinate Y, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop end position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropUnit","in":"query","description":"The unit of the crop parameters. Can be either 'percent' or 'pixel'. If omitted the unit is assumed to be 'percent'","required":false,"schema":{"type":"string"}},{"name":"focalX","in":"query","description":"The end image coordinate X, in percent (0 to 100), specifying the focal point. If used the other focal point parameter, width and/or height, must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"focalY","in":"query","description":"The end image coordinate Y, in percent (0 to 100), specifying the focal point. If used the other focal point parameter, width and/or height, must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"ratio","in":"query","description":"The wanted aspect ratio, defined as width/height. To be used together with the focal parameters. If used the width and height is ignored and derived from the aspect ratio instead.","required":false,"schema":{"type":"number","format":"double"}},{"name":"language","in":"query","description":"The wanted aspect ratio, defined as width/height. To be used together with the focal parameters. If used the width and height is ignored and derived from the aspect ratio instead.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}},"/image-api/raw/{image_name}":{"get":{"tags":["raw"],"summary":"Fetch an image with options to resize and crop","description":"Fetches a image with options to resize and crop","operationId":"getImage-apiRawImage_name","parameters":[{"name":"image_name","in":"path","description":"The name of the image","required":true,"schema":{"type":"string"}},{"name":"app-key","in":"header","description":"Your app-key. May be omitted to access api anonymously, but rate limiting may apply on anonymous access.","required":false,"schema":{"type":"string"}},{"name":"width","in":"query","description":"The target width to resize the image (the unit is pixles). Image proportions are kept intact","required":false,"schema":{"type":"number","format":"double"}},{"name":"height","in":"query","description":"The target height to resize the image (the unit is pixles). Image proportions are kept intact","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropStartX","in":"query","description":"The first image coordinate X, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop start position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropStartY","in":"query","description":"The first image coordinate Y, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop start position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropEndX","in":"query","description":"The end image coordinate X, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop end position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropEndY","in":"query","description":"The end image coordinate Y, in percent (0 to 100) or pixels depending on cropUnit, specifying the crop end position. If used the other crop parameters must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"cropUnit","in":"query","description":"The unit of the crop parameters. Can be either 'percent' or 'pixel'. If omitted the unit is assumed to be 'percent'","required":false,"schema":{"type":"string"}},{"name":"focalX","in":"query","description":"The end image coordinate X, in percent (0 to 100), specifying the focal point. If used the other focal point parameter, width and/or height, must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"focalY","in":"query","description":"The end image coordinate Y, in percent (0 to 100), specifying the focal point. If used the other focal point parameter, width and/or height, must also be supplied","required":false,"schema":{"type":"number","format":"double"}},{"name":"ratio","in":"query","description":"The wanted aspect ratio, defined as width/height. To be used together with the focal parameters. If used the width and height is ignored and derived from the aspect ratio instead.","required":false,"schema":{"type":"number","format":"double"}},{"name":"language","in":"query","description":"The wanted aspect ratio, defined as width/height. To be used together with the focal parameters. If used the width and height is ignored and derived from the aspect ratio instead.","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"404":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllErrors"}}}},"500":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorBody"}}}}}}}},"components":{"schemas":{"AllErrors":{"title":"AllErrors","oneOf":[{"$ref":"#/components/schemas/ErrorBody"},{"$ref":"#/components/schemas/NotFoundWithSupportedLanguages"},{"$ref":"#/components/schemas/ValidationErrorBody"}]},"AuthorDTO":{"title":"AuthorDTO","description":"Information about an author","type":"object","required":["type","name"],"properties":{"type":{"$ref":"#/components/schemas/ContributorType"},"name":{"description":"The name of the of the author","type":"string"}}},"ContributorType":{"title":"ContributorType","description":"The description of the author. Eg. Photographer or Supplier","type":"string","enum":["artist","cowriter","compiler","composer","correction","director","distributor","editorial","facilitator","idea","illustrator","linguistic","originator","photographer","processor","publisher","reader","rightsholder","scriptwriter","supplier","translator","writer"]},"CopyMetaDataAndFileForm":{"title":"CopyMetaDataAndFileForm","type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary"}}},"CopyrightDTO":{"title":"CopyrightDTO","description":"Describes the copyright information for the image","type":"object","required":["license","creators","processors","rightsholders","processed"],"properties":{"license":{"$ref":"#/components/schemas/LicenseDTO"},"origin":{"description":"Reference to where the article is procured","type":"string"},"creators":{"description":"List of creators","type":"array","items":{"$ref":"#/components/schemas/AuthorDTO"}},"processors":{"description":"List of processors","type":"array","items":{"$ref":"#/components/schemas/AuthorDTO"}},"rightsholders":{"description":"List of rightsholders","type":"array","items":{"$ref":"#/components/schemas/AuthorDTO"}},"validFrom":{"description":"Date from which the copyright is valid","type":"string"},"validTo":{"description":"Date to which the copyright is valid","type":"string"},"processed":{"description":"Whether or not the content has been processed","type":"boolean"}}},"EditorNoteDTO":{"title":"EditorNoteDTO","description":"Note about a change that happened to the image","type":"object","required":["timestamp","updatedBy","note"],"properties":{"timestamp":{"description":"Timestamp of the change","type":"string"},"updatedBy":{"description":"Who triggered the change","type":"string"},"note":{"description":"Editorial note","type":"string"}}},"ErrorBody":{"title":"ErrorBody","description":"Information about an error","type":"object","required":["code","description","occurredAt","statusCode"],"properties":{"code":{"description":"Code stating the type of error","type":"string"},"description":{"description":"Description of the error","type":"string"},"occurredAt":{"description":"When the error occurred","type":"string"},"statusCode":{"description":"Numeric http status code","type":"integer","format":"int32"}}},"ImageAltTextDTO":{"title":"ImageAltTextDTO","type":"object","required":["alttext","language"],"properties":{"alttext":{"description":"The alternative text for the image","type":"string"},"language":{"description":"ISO 639-1 code that represents the language used in the alternative text","type":"string"}}},"ImageCaptionDTO":{"title":"ImageCaptionDTO","type":"object","required":["caption","language"],"properties":{"caption":{"description":"The caption for the image","type":"string"},"language":{"description":"ISO 639-1 code that represents the language used in the caption","type":"string"}}},"ImageDimensionsDTO":{"title":"ImageDimensionsDTO","description":"Dimensions of the image","type":"object","required":["width","height"],"properties":{"width":{"description":"The width of the image in pixels","type":"integer","format":"int32"},"height":{"description":"The height of the image in pixels","type":"integer","format":"int32"}}},"ImageFileDTO":{"title":"ImageFileDTO","description":"Describes the image file","type":"object","required":["fileName","size","contentType","imageUrl","variants","language"],"properties":{"fileName":{"description":"File name pointing to image file","type":"string"},"size":{"description":"The size of the image in bytes","type":"integer","format":"int64"},"contentType":{"description":"The mimetype of the image","type":"string"},"imageUrl":{"description":"The full url to where the image can be downloaded","type":"string"},"dimensions":{"$ref":"#/components/schemas/ImageDimensionsDTO"},"variants":{"description":"Size variants of the image","type":"array","items":{"$ref":"#/components/schemas/ImageVariantDTO"}},"language":{"description":"ISO 639-1 code that represents the language used in the caption","type":"string"}}},"ImageMetaInformationV2DTO":{"title":"ImageMetaInformationV2DTO","description":"Meta information for the image","type":"object","required":["id","metaUrl","title","alttext","imageUrl","size","contentType","copyright","tags","caption","supportedLanguages","created","createdBy","modelRelease"],"properties":{"id":{"description":"The unique id of the image","type":"string"},"metaUrl":{"description":"The url to where this information can be found","type":"string"},"title":{"$ref":"#/components/schemas/ImageTitleDTO","description":"The title for the image"},"alttext":{"$ref":"#/components/schemas/ImageAltTextDTO","description":"Alternative text for the image"},"imageUrl":{"description":"The full url to where the image can be downloaded","type":"string"},"size":{"description":"The size of the image in bytes","type":"integer","format":"int64"},"contentType":{"description":"The mimetype of the image","type":"string"},"copyright":{"$ref":"#/components/schemas/CopyrightDTO"},"tags":{"$ref":"#/components/schemas/ImageTagDTO"},"caption":{"$ref":"#/components/schemas/ImageCaptionDTO","description":"Searchable caption for the image"},"supportedLanguages":{"description":"Supported languages for the image title, alt-text, tags and caption.","type":"array","items":{"type":"string"}},"created":{"description":"Describes when the image was created","type":"string"},"createdBy":{"description":"Describes who created the image","type":"string"},"modelRelease":{"description":"Describes if the model has released use of the image","type":"string"},"editorNotes":{"description":"Describes the changes made to the image, only visible to editors","type":"array","items":{"$ref":"#/components/schemas/EditorNoteDTO"}},"imageDimensions":{"$ref":"#/components/schemas/ImageDimensionsDTO"}}},"ImageMetaInformationV3DTO":{"title":"ImageMetaInformationV3DTO","description":"Meta information for the image","type":"object","required":["id","metaUrl","title","alttext","copyright","tags","caption","supportedLanguages","created","createdBy","modelRelease","image","inactive"],"properties":{"id":{"description":"The unique id of the image","type":"string"},"metaUrl":{"description":"The url to where this information can be found","type":"string"},"title":{"$ref":"#/components/schemas/ImageTitleDTO","description":"The title for the image"},"alttext":{"$ref":"#/components/schemas/ImageAltTextDTO","description":"Alternative text for the image"},"copyright":{"$ref":"#/components/schemas/CopyrightDTO"},"tags":{"$ref":"#/components/schemas/ImageTagDTO"},"caption":{"$ref":"#/components/schemas/ImageCaptionDTO","description":"Searchable caption for the image"},"supportedLanguages":{"description":"Supported languages for the image title, alt-text, tags and caption.","type":"array","items":{"type":"string"}},"created":{"description":"Describes when the image was created","type":"string"},"createdBy":{"description":"Describes who created the image","type":"string"},"modelRelease":{"description":"Describes if the model has released use of the image","type":"string"},"editorNotes":{"description":"Describes the changes made to the image, only visible to editors","type":"array","items":{"$ref":"#/components/schemas/EditorNoteDTO"}},"image":{"$ref":"#/components/schemas/ImageFileDTO"},"inactive":{"description":"Describes if the image is inactive or not","type":"boolean"}}},"ImageMetaSummaryDTO":{"title":"ImageMetaSummaryDTO","description":"Summary of meta information for an image","type":"object","required":["id","title","contributors","altText","caption","previewUrl","metaUrl","license","supportedLanguages","lastUpdated","fileSize","contentType","inactive"],"properties":{"id":{"description":"The unique id of the image","type":"string"},"title":{"$ref":"#/components/schemas/ImageTitleDTO","description":"The title for this image"},"contributors":{"description":"The copyright authors for this image","type":"array","items":{"type":"string"}},"altText":{"$ref":"#/components/schemas/ImageAltTextDTO","description":"The alt text for this image"},"caption":{"$ref":"#/components/schemas/ImageCaptionDTO","description":"The caption for this image"},"previewUrl":{"description":"The full url to where a preview of the image can be downloaded","type":"string"},"metaUrl":{"description":"The full url to where the complete metainformation about the image can be found","type":"string"},"license":{"description":"Describes the license of the image","type":"string"},"supportedLanguages":{"description":"List of supported languages in priority","type":"array","items":{"type":"string"}},"modelRelease":{"description":"Describes if the model has released use of the image","type":"string"},"editorNotes":{"description":"Describes the changes made to the image, only visible to editors","type":"array","items":{"type":"string"}},"lastUpdated":{"description":"The time and date of last update","type":"string"},"fileSize":{"description":"The size of the image in bytes","type":"integer","format":"int64"},"contentType":{"description":"The mimetype of the image","type":"string"},"imageDimensions":{"$ref":"#/components/schemas/ImageDimensionsDTO"},"inactive":{"description":"Whether the image is inactive or not","type":"boolean"}}},"ImageTagDTO":{"title":"ImageTagDTO","description":"Searchable tags for the image","type":"object","required":["tags","language"],"properties":{"tags":{"description":"The searchable tag.","type":"array","items":{"type":"string"}},"language":{"description":"ISO 639-1 code that represents the language used in tag","type":"string"}}},"ImageTitleDTO":{"title":"ImageTitleDTO","type":"object","required":["title","language"],"properties":{"title":{"description":"The freetext title of the image","type":"string"},"language":{"description":"ISO 639-1 code that represents the language used in title","type":"string"}}},"ImageVariantDTO":{"title":"ImageVariantDTO","type":"object","required":["size","variantUrl"],"properties":{"size":{"$ref":"#/components/schemas/ImageVariantSize"},"variantUrl":{"description":"The full URL to where the image variant can be downloaded","type":"string"}}},"ImageVariantSize":{"title":"ImageVariantSize","description":"The named size of this image variant","type":"string","enum":["icon","xsmall","small","medium","large","xlarge","xxlarge"]},"LicenseDTO":{"title":"LicenseDTO","description":"Describes the license of the article","type":"object","required":["license"],"properties":{"license":{"description":"The name of the license","type":"string"},"description":{"description":"Description of the license","type":"string"},"url":{"description":"Url to where the license can be found","type":"string"}}},"MetaDataAndFileForm":{"title":"MetaDataAndFileForm","type":"object","required":["metadata","file"],"properties":{"metadata":{"$ref":"#/components/schemas/NewImageMetaInformationV2DTO"},"file":{"type":"string","format":"binary"}}},"NewImageMetaInformationV2DTO":{"title":"NewImageMetaInformationV2DTO","description":"Meta information for the image","type":"object","required":["title","copyright","tags","caption","language"],"properties":{"title":{"description":"Title for the image","type":"string"},"alttext":{"description":"Alternative text for the image","type":"string"},"copyright":{"$ref":"#/components/schemas/CopyrightDTO"},"tags":{"description":"Searchable tags for the image","type":"array","items":{"type":"string"}},"caption":{"description":"Caption for the image","type":"string"},"language":{"description":"ISO 639-1 code that represents the language used in the caption","type":"string"},"modelReleased":{"description":"Describes if the model has released use of the image, allowed values are 'not-set', 'yes', 'no', and 'not-applicable', defaults to 'no'","type":"string"}}},"NotFoundWithSupportedLanguages":{"title":"NotFoundWithSupportedLanguages","description":"Information about an error","type":"object","required":["code","description","occurredAt","statusCode"],"properties":{"code":{"description":"Code stating the type of error","type":"string"},"description":{"description":"Description of the error","type":"string"},"occurredAt":{"description":"When the error occurred","type":"string"},"supportedLanguages":{"description":"List of supported languages","type":"array","items":{"type":"string"}},"statusCode":{"description":"Numeric http status code","type":"integer","format":"int32"}}},"SearchParamsDTO":{"title":"SearchParamsDTO","description":"The search parameters","type":"object","properties":{"query":{"description":"Return only images with titles, alt-texts or tags matching the specified query.","type":"string"},"license":{"description":"Return only images with provided license.","type":"string"},"language":{"description":"The ISO 639-1 language code describing language used in query-params","type":"string"},"fallback":{"description":"Fallback to existing language if language is specified.","type":"boolean"},"minimumSize":{"description":"Return only images with full size larger than submitted value in bytes.","type":"integer","format":"int32"},"includeCopyrighted":{"description":"Return copyrighted images. May be omitted.","deprecated":true,"type":"boolean"},"sort":{"$ref":"#/components/schemas/Sort"},"page":{"description":"The page number of the search hits to display.","type":"integer","format":"int32"},"pageSize":{"description":"The number of search hits to display for each page.","type":"integer","format":"int32"},"podcastFriendly":{"description":"Only show podcast friendly images. Same width and height, and between 1400 and 3000 pixels.","type":"boolean"},"scrollId":{"description":"A search context retrieved from the response header of a previous search.","type":"string"},"inactive":{"description":"Include inactive images","type":"boolean"},"modelReleased":{"description":"Return only images with one of the provided values for modelReleased.","type":"array","items":{"type":"string"}},"users":{"description":"Filter editors of the image(s). Multiple values can be specified in a comma separated list.","type":"array","items":{"type":"string"}},"widthFrom":{"description":"Filter images with width greater than or equal to this value.","type":"integer","format":"int32"},"widthTo":{"description":"Filter images with width less than or equal to this value.","type":"integer","format":"int32"},"heightFrom":{"description":"Filter images with height greater than or equal to this value.","type":"integer","format":"int32"},"heightTo":{"description":"Filter images with height less than or equal to this value.","type":"integer","format":"int32"},"contentType":{"description":"Filter images by content type (e.g., 'image/jpeg', 'image/png').","type":"string"}}},"SearchResultDTO":{"title":"SearchResultDTO","description":"Information about search-results","type":"object","required":["totalCount","pageSize","language","results"],"properties":{"totalCount":{"description":"The total number of images matching this query","type":"integer","format":"int64"},"page":{"description":"For which page results are shown from","type":"integer","format":"int32"},"pageSize":{"description":"The number of results per page","type":"integer","format":"int32"},"language":{"description":"The chosen search language","type":"string"},"results":{"description":"The search results","type":"array","items":{"$ref":"#/components/schemas/ImageMetaSummaryDTO"}}}},"SearchResultV3DTO":{"title":"SearchResultV3DTO","description":"Information about search-results","type":"object","required":["totalCount","pageSize","language","results"],"properties":{"totalCount":{"description":"The total number of images matching this query","type":"integer","format":"int64"},"page":{"description":"For which page results are shown from","type":"integer","format":"int32"},"pageSize":{"description":"The number of results per page","type":"integer","format":"int32"},"language":{"description":"The chosen search language","type":"string"},"results":{"description":"The search results","type":"array","items":{"$ref":"#/components/schemas/ImageMetaInformationV3DTO"}}}},"Sort":{"title":"Sort","description":"The sorting used on results. The following are supported: relevance, -relevance, title, -title, lastUpdated, -lastUpdated, id, -id. Default is by -relevance (desc) when query is set, and title (asc) when query is empty.","type":"string","enum":["-relevance","relevance","-title","title","-lastUpdated","lastUpdated","-id","id"]},"TagsSearchResultDTO":{"title":"TagsSearchResultDTO","description":"Information about tags-search-results","type":"object","required":["totalCount","page","pageSize","language","results"],"properties":{"totalCount":{"description":"The total number of tags matching this query","type":"integer","format":"int64"},"page":{"description":"For which page results are shown from","type":"integer","format":"int32"},"pageSize":{"description":"The number of results per page","type":"integer","format":"int32"},"language":{"description":"The chosen search language","type":"string"},"results":{"description":"The search results","type":"array","items":{"type":"string"}}}},"UpdateImageMetaInformationDTO":{"title":"UpdateImageMetaInformationDTO","description":"Meta information for the image","type":"object","required":["language"],"properties":{"language":{"description":"ISO 639-1 code that represents the language","type":"string"},"title":{"description":"Title for the image","type":"string"},"alttext":{"description":"Alternative text for the image","type":["string","null"]},"copyright":{"$ref":"#/components/schemas/CopyrightDTO"},"tags":{"description":"Searchable tags for the image","type":"array","items":{"type":"string"}},"caption":{"description":"Caption for the image","type":"string"},"modelReleased":{"description":"Describes if the model has released use of the image","type":"string"},"inactive":{"description":"Whether the image is inactive","type":"boolean"}}},"UpdateMetaDataAndFileForm":{"title":"UpdateMetaDataAndFileForm","type":"object","required":["metadata"],"properties":{"metadata":{"$ref":"#/components/schemas/UpdateImageMetaInformationDTO"},"file":{"type":"string","format":"binary"}}},"ValidationErrorBody":{"title":"ValidationErrorBody","description":"Information about an error","type":"object","required":["code","description","occurredAt","statusCode"],"properties":{"code":{"description":"Code stating the type of error","type":"string"},"description":{"description":"Description of the error","type":"string"},"occurredAt":{"description":"When the error occurred","type":"string"},"messages":{"description":"List of validation messages","type":"array","items":{"$ref":"#/components/schemas/ValidationMessage"}},"statusCode":{"description":"Numeric http status code","type":"integer","format":"int32"}}},"ValidationMessage":{"title":"ValidationMessage","description":"A message describing a validation error on a specific field","type":"object","required":["field","message"],"properties":{"field":{"description":"The field the error occured in","type":"string"},"message":{"description":"The validation message","type":"string"}}}},"securitySchemes":{"oauth2":{"type":"oauth2","flows":{"implicit":{"authorizationUrl":"https://ndla-test.eu.auth0.com/authorize","scopes":{"concept:admin":"concept:admin","concept:write":"concept:write"}}}}}}} \ No newline at end of file From 9ac09daef47f993398c5da88da8ea610d4ecf779 Mon Sep 17 00:00:00 2001 From: Gunnar Velle Date: Fri, 27 Feb 2026 07:05:57 +0100 Subject: [PATCH 6/6] Drop binary content type --- .../no/ndla/imageapi/ImageApiProperties.scala | 2 +- .../no/ndla/imageapi/model/NDLAErrors.scala | 4 ++-- .../model/domain/ImageContentType.scala | 11 +++------ .../imageapi/service/ImageConverter.scala | 22 ++++++++--------- .../service/ImageStorageService.scala | 17 ++++++------- .../ndla/imageapi/service/WriteService.scala | 2 +- .../service/ImageStorageServiceTest.scala | 24 ++++++++++++++++--- 7 files changed, 46 insertions(+), 36 deletions(-) diff --git a/image-api/src/main/scala/no/ndla/imageapi/ImageApiProperties.scala b/image-api/src/main/scala/no/ndla/imageapi/ImageApiProperties.scala index 598d375dd8..34ded72e17 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/ImageApiProperties.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/ImageApiProperties.scala @@ -32,7 +32,7 @@ class ImageApiProperties extends BaseProps with DatabaseProps with StrictLogging val ImageControllerV3Path: String = s"$ImageApiBasePath/v3/images" val RawControllerPath: String = s"$ImageApiBasePath/raw" - val ValidMimeTypes: Seq[ImageContentType] = ImageContentType.values.filter(_.image) + val ValidMimeTypes: Seq[ImageContentType] = ImageContentType.values val IsoMappingCacheAgeInMs: Int = 1000 * 60 * 60 // 1 hour caching val LicenseMappingCacheAgeInMs: Int = 1000 * 60 * 60 // 1 hour caching diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/NDLAErrors.scala b/image-api/src/main/scala/no/ndla/imageapi/model/NDLAErrors.scala index 6fdceb89e6..aa0a3a67a6 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/model/NDLAErrors.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/model/NDLAErrors.scala @@ -10,7 +10,6 @@ package no.ndla.imageapi.model import no.ndla.common.errors.MultipleExceptions import no.ndla.imageapi.Props -import no.ndla.imageapi.model.domain.ImageContentType class ImageNotFoundException(message: String) extends RuntimeException(message) @@ -24,7 +23,8 @@ case class ImageDeleteException(message: String, exs: Seq[Throwable]) ex case class ImageVariantsUploadException(message: String, exs: Seq[Throwable]) extends MultipleExceptions(message, exs) case class ImageConversionException(message: String) extends RuntimeException(message) case class ImageCopyException(message: String) extends RuntimeException(message) -case class ImageUnprocessableFormatException(contentType: ImageContentType) +case class ImageContentTypeException(message: String) extends RuntimeException(message) +case class ImageUnprocessableFormatException(contentType: String) extends RuntimeException(s"Image of '${contentType}' Content-Type did not have a processable binary format") object ImageErrorHelpers { diff --git a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageContentType.scala b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageContentType.scala index 60aa3adfd4..816a249972 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageContentType.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/model/domain/ImageContentType.scala @@ -11,16 +11,12 @@ package no.ndla.imageapi.model.domain import enumeratum.{Enum, EnumEntry} import no.ndla.common.CirceUtil.CirceEnumWithErrors -sealed abstract class ImageContentType( - override val entryName: String, - val fileEndings: List[String], - val image: Boolean = true, -) extends EnumEntry { +sealed abstract class ImageContentType(override val entryName: String, val fileEndings: List[String]) + extends EnumEntry { override def toString: String = entryName } object ImageContentType extends Enum[ImageContentType], CirceEnumWithErrors[ImageContentType] { - case object Binary extends ImageContentType("binary/octet-stream", List(), false) case object Bmp extends ImageContentType("image/bmp", List(".bmp")) case object Gif extends ImageContentType("image/gif", List(".gif")) case object Jpeg extends ImageContentType("image/jpeg", List(".jpg", ".jpeg")) @@ -32,6 +28,5 @@ object ImageContentType extends Enum[ImageContentType], CirceEnumWithErrors[Imag case object Webp extends ImageContentType("image/webp", List(".webp")) override def values: IndexedSeq[ImageContentType] = findValues - - def valueOf(s: String): Option[ImageContentType] = ImageContentType.values.find(_.entryName == s) + def valueOf(s: String): Option[ImageContentType] = ImageContentType.values.find(_.entryName == s) } diff --git a/image-api/src/main/scala/no/ndla/imageapi/service/ImageConverter.scala b/image-api/src/main/scala/no/ndla/imageapi/service/ImageConverter.scala index 7168767bad..bf4caac6cf 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/service/ImageConverter.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/service/ImageConverter.scala @@ -51,32 +51,32 @@ class ImageConverter(using props: Props) extends StrictLogging { s3Object.stream, s3Object.key, s3Object.contentLength, - ImageContentType.valueOf(s3Object.contentType).getOrElse(ImageContentType.Binary), + ImageContentType.valueOf(s3Object.contentType), ) def uploadedFileToImageStream(file: UploadedFile, fileName: String): Try[ImageStream] = inputStreamToImageStream( file.createStream(), fileName, file.fileSize, - file.contentType.flatMap(ImageContentType.valueOf).getOrElse(ImageContentType.Binary), + file.contentType.flatMap(ImageContentType.valueOf), ) private def maybeScrimageFormatToImageStream( stream: BufferedInputStream, fileName: String, contentLength: Long, - contentType: ImageContentType, + contentType: Option[ImageContentType], maybeScrimageFormat: Try[Option[Format]], ): Try[ImageStream] = { import ImageStream.* maybeScrimageFormat.flatMap { - case Some(Format.GIF) => Success(Gif(stream, fileName, contentLength)) - case Some(Format.PNG) => Success(Processable(stream, fileName, contentLength, ProcessableImageFormat.Png)) - case Some(Format.JPEG) => Success(Processable(stream, fileName, contentLength, ProcessableImageFormat.Jpeg)) - case Some(Format.WEBP) => Success(Processable(stream, fileName, contentLength, ProcessableImageFormat.Webp)) - case None if contentType == ImageContentType.Svg => - Success(Unprocessable(stream, fileName, contentLength, contentType)) - case None => Failure(ImageUnprocessableFormatException(contentType)) + case Some(Format.GIF) => Success(Gif(stream, fileName, contentLength)) + case Some(Format.PNG) => Success(Processable(stream, fileName, contentLength, ProcessableImageFormat.Png)) + case Some(Format.JPEG) => Success(Processable(stream, fileName, contentLength, ProcessableImageFormat.Jpeg)) + case Some(Format.WEBP) => Success(Processable(stream, fileName, contentLength, ProcessableImageFormat.Webp)) + case None if contentType.contains(ImageContentType.Svg) => + Success(Unprocessable(stream, fileName, contentLength, contentType.get)) + case None => Failure(ImageUnprocessableFormatException("unknown")) } } @@ -84,7 +84,7 @@ class ImageConverter(using props: Props) extends StrictLogging { inputStream: InputStream, fileName: String, contentLength: Long, - contentType: ImageContentType, + contentType: Option[ImageContentType], ): Try[ImageStream] = Try .throwIfInterrupted { // Use buffered stream with mark to avoid creating multiple streams diff --git a/image-api/src/main/scala/no/ndla/imageapi/service/ImageStorageService.scala b/image-api/src/main/scala/no/ndla/imageapi/service/ImageStorageService.scala index 55cc221401..7a9b403a5a 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/service/ImageStorageService.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/service/ImageStorageService.scala @@ -13,6 +13,7 @@ import cats.implicits.* import com.typesafe.scalalogging.StrictLogging import no.ndla.common.aws.{NdlaS3Client, NdlaS3Object} import no.ndla.imageapi.Props +import no.ndla.imageapi.model.ImageContentTypeException import no.ndla.imageapi.model.domain.{ImageContentType, ImageStream} import java.io.InputStream @@ -24,14 +25,10 @@ class ImageStorageService(using imageConverter: ImageConverter, props: Props, ) extends StrictLogging { - private def ensureS3ContentType(s3Object: NdlaS3Object): ImageContentType = { - val s3ContentType = ImageContentType.valueOf(s3Object.contentType) - val fileName = s3Object.key - if (s3ContentType.contains(ImageContentType.Binary)) { + private def ensureS3ContentType(s3Object: NdlaS3Object): Try[Unit] = { + val fileName = s3Object.key + if (s3Object.contentType == "binary/octet-stream") { readService.getImageFileFromFilePath(fileName) match { - case Failure(ex) => - logger.warn(s"Couldn't get meta for $fileName so using s3 content-type of '$s3ContentType.get'", ex) - s3ContentType.get case Success(meta) if props.ValidMimeTypes.contains(meta.contentType) => updateContentType(s3Object.key, meta.contentType) match { case Failure(ex) => @@ -39,10 +36,10 @@ class ImageStorageService(using case Success(_) => logger.info(s"Successfully updated content-type s3-metadata of $fileName to ${meta.contentType}") } - meta.contentType - case _ => s3ContentType.get + Success(()) + case _ => Failure(ImageContentTypeException("Image content type unknown")) } - } else s3ContentType.get + } else Success(()) } def get(imageKey: String): Try[ImageStream] = { diff --git a/image-api/src/main/scala/no/ndla/imageapi/service/WriteService.scala b/image-api/src/main/scala/no/ndla/imageapi/service/WriteService.scala index 80be1bfe46..47b134b09f 100644 --- a/image-api/src/main/scala/no/ndla/imageapi/service/WriteService.scala +++ b/image-api/src/main/scala/no/ndla/imageapi/service/WriteService.scala @@ -470,7 +470,7 @@ class WriteService(using // We only process image/jpeg and image/png in this job, so a GIF or unprocessable image is an error case stream: ( ImageStream.Gif | ImageStream.Unprocessable - ) => Failure(ImageUnprocessableFormatException(stream.contentType)) + ) => Failure(ImageUnprocessableFormatException("unprocessable format")) } .recoverWith { ex => Try(s3Object.stream.close()) match { diff --git a/image-api/src/test/scala/no/ndla/imageapi/service/ImageStorageServiceTest.scala b/image-api/src/test/scala/no/ndla/imageapi/service/ImageStorageServiceTest.scala index 43b23bf123..140095fb9f 100644 --- a/image-api/src/test/scala/no/ndla/imageapi/service/ImageStorageServiceTest.scala +++ b/image-api/src/test/scala/no/ndla/imageapi/service/ImageStorageServiceTest.scala @@ -12,10 +12,12 @@ import no.ndla.common.aws.NdlaS3Object import no.ndla.imageapi.model.domain.{ImageContentType, ImageMetaInformation} import no.ndla.imageapi.service.ImageStorageService import no.ndla.imageapi.{TestEnvironment, UnitSuite} -import org.mockito.ArgumentMatchers.any -import org.mockito.Mockito.{reset, when} -import software.amazon.awssdk.services.s3.model.NoSuchKeyException +import org.mockito.ArgumentMatchers.{any, anyMap, anyString} +import org.mockito.Mockito.{reset, times, verify, when} +import software.amazon.awssdk.services.s3.model.{HeadObjectResponse, NoSuchKeyException} +import scala.jdk.CollectionConverters.MutableMapHasAsJava +import scala.collection.mutable import scala.util.{Failure, Success} class ImageStorageServiceTest extends UnitSuite with TestEnvironment { @@ -54,4 +56,20 @@ class ImageStorageServiceTest extends UnitSuite with TestEnvironment { assert(imageStorage.get("nonexisting").isFailure) } + test("That AmazonImageStorage.get fixes content-type when it is binary/octet-stream") { + val s3Object = NdlaS3Object("bucket", "existing", TestData.ndlaLogoImageStream.stream, "binary/octet-stream", 100) + val headResponse = mock[HeadObjectResponse] + + when(s3Client.getObject(any)).thenReturn(Success(s3Object)) + when(s3Client.headObject(anyString())).thenReturn(Success(headResponse)) + when(headResponse.metadata()).thenReturn(mutable.Map("content-type" -> "image/jpeg").asJava) + when(s3Client.updateMetadata(anyString(), anyMap())).thenReturn(Success(())) + when(readService.getImageFileFromFilePath(any)).thenReturn(Success(ImageWithNoThumb.images.head)) + imageStorage.get("existing") + + verify(s3Client, times(1)).getObject(anyString()) + verify(s3Client, times(1)).headObject(anyString()) + verify(s3Client, times(1)).updateMetadata(anyString(), anyMap()) + } + }