Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ final class DigitalNZAPIDataSource: Sendable {

func newResult(
collection: String?,
weightsOverride: OrderedDictionary<String, Double>? = nil,
logger: (String) -> Void
)
async throws -> NZRecordsResult
Expand All @@ -47,7 +48,9 @@ final class DigitalNZAPIDataSource: Sendable {
chosenCollection = collection
}
else {
chosenCollection = collectionWeights.weightedRandomPick()
// Use the per-call override (already filtered + renormalized by the caller) when
// provided; otherwise fall back to the unfiltered weights captured at init.
chosenCollection = (weightsOverride ?? collectionWeights).weightedRandomPick()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

let secondRequestResultsPerPage = 100
Expand Down
19 changes: 17 additions & 2 deletions Sources/NZImageApiLambda/Models/NZRecordsResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ public struct NZRecordsResult: NonNullableResult, Codable, Sendable {
collection: String?,
landingUrl: URL?,
originUrl: URL? = nil,
sourceUrl: URL? = nil
sourceUrl: URL? = nil,
creator: [String]? = nil,
date: [String]? = nil,
subject: [String]? = nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) {
self.id = id
self.title = title
Expand All @@ -33,6 +36,9 @@ public struct NZRecordsResult: NonNullableResult, Codable, Sendable {
self.landingUrl = landingUrl
self.originUrl = originUrl
self.sourceUrl = sourceUrl
self.creator = creator
self.date = date
self.subject = subject
}

// MARK: Internal
Expand Down Expand Up @@ -62,6 +68,9 @@ public struct NZRecordsResult: NonNullableResult, Codable, Sendable {
case landingUrl = "landing_url"
case originUrl = "origin_url"
case sourceUrl = "source_url"
case creator
case date
case subject
}

public var id: Int?
Expand All @@ -74,6 +83,9 @@ public struct NZRecordsResult: NonNullableResult, Codable, Sendable {
public var landingUrl: URL?
public var originUrl: URL?
public var sourceUrl: URL?
public var creator: [String]?
public var date: [String]?
public var subject: [String]?

func customDescription() -> String {
return """
Expand All @@ -86,7 +98,10 @@ public struct NZRecordsResult: NonNullableResult, Codable, Sendable {
collection: \(String(describing: collection)),
landingUrl: \(String(describing: landingUrl)),
originUrl: \(String(describing: originUrl)),
sourceUrl: \(String(describing: sourceUrl))
sourceUrl: \(String(describing: sourceUrl)),
creator: \(String(describing: creator)),
date: \(String(describing: date)),
subject: \(String(describing: subject))
"""
}

Expand Down
35 changes: 34 additions & 1 deletion Sources/NZImageApiLambda/NZImageApi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,47 @@ public struct NZImageApi: Sendable {
return collectionImageCounts.mapValues { Double($0) / total }
}

public func image(collection: String?, logger: @Sendable (String) -> Void = { _ in }) async -> NZRecordsResult? {
/// Thrown when `exclude` removes every collection, leaving nothing eligible to pick from.
/// Surfaced to the handler so it can return a `.badRequest` rather than silently falling
/// back to the full unfiltered set.
public struct NoEligibleCollectionsError: Error {}

/// Weights over `collectionImageCounts` minus `exclude`, re-derived as `count / filtered-total`
/// so they still sum to 1 (mod float rounding) over the remaining set — never
/// subtract-then-leave-unnormalized, which reintroduces the documented "weights don't sum to 1"
/// bug. Throws `NoEligibleCollectionsError` if `exclude` removes every collection.
public static func filteredCollectionWeights(excluding exclude: Set<String>) throws -> OrderedDictionary<String, Double> {
let filteredCounts = collectionImageCounts.filter { !exclude.contains($0.key) }
guard !filteredCounts.isEmpty else { throw NoEligibleCollectionsError() }
let total = Double(filteredCounts.values.reduce(0, +))
return filteredCounts.mapValues { Double($0) / total }
}

public func image(
collection: String?,
exclude: Set<String> = [],
logger: @Sendable (String) -> Void = { _ in }
) async -> NZRecordsResult? {
do {
// An explicit single collection always wins; `exclude` is only consulted for the
// random path. When `exclude` is non-empty we filter+renormalize the canonical counts.
var weightsOverride: OrderedDictionary<String, Double>? = nil
if collection == nil, !exclude.isEmpty {
weightsOverride = try NZImageApi.filteredCollectionWeights(excluding: exclude)
}

let result = try await digitalNZAPIDataSource.newResult(
collection: collection,
weightsOverride: weightsOverride,
logger: logger
)
return result
}
catch is NoEligibleCollectionsError {
// Expected, user-triggerable (an `exclude` list that covers every collection) --
// logged distinctly from the catch-all below so it doesn't read as a genuine bug.
logger("No eligible collections remain after applying the exclude filter")
}
catch {
if let richError = error as? (any RichError) {
// Get the raw value from the enum that defines the kind of error. Messy due to RichError being a protocol and the nested
Expand Down
24 changes: 23 additions & 1 deletion Sources/NZImageApiLambda/NZImageApiLambda.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,34 @@ struct NZImageApiLambda {
context.logger.log(level: .info, "Authorized consumer: \(consumer)")

switch (event.context.http.path, event.context.http.method) {
case ("/collections", .get):
let jsonEncoder = JSONEncoder()
jsonEncoder.outputFormatting = .withoutEscapingSlashes
let jsonData = try jsonEncoder.encode(Array(NZImageApi.collectionImageCounts.keys))
let jsonString = String(data: jsonData, encoding: .utf8)

return APIGatewayV2Response(statusCode: .ok, headers: ["content-type": "application/json"], body: jsonString)

case ("/image", .get):
let requestedCollection = event.queryStringParameters["collection"]

// `collection` (single explicit override) wins over `exclude`; only consult `exclude`
// when no explicit collection was requested.
let exclude: Set<String>
if requestedCollection != nil {
exclude = []
}
else if let excludeParam = event.queryStringParameters["exclude"], !excludeParam.isEmpty {
exclude = Set(excludeParam.split(separator: ",").map(String.init))
}
else {
exclude = []
}

context.logger.log(level: .info, "Requested collection: \(requestedCollection ?? "random")")
context.logger.log(level: .info, "Excluded collections: \(exclude.isEmpty ? "none" : exclude.sorted().joined(separator: ", "))")

guard let image = await api.image(collection: requestedCollection, logger: { log in
guard let image = await api.image(collection: requestedCollection, exclude: exclude, logger: { log in
context.logger.log(level: .info, "\(log)")
}) else {
context.logger.log(level: .error, "Failed to get image for collection: \(requestedCollection ?? "random")")
Expand Down
76 changes: 76 additions & 0 deletions Tests/NZImageApiLambdaTests/FilteredCollectionWeightsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//
// FilteredCollectionWeightsTests.swift
//
// Coverage for the `?exclude=` renormalization path (`NZImageApi.filteredCollectionWeights`).
// The invariant mirrors CollectionWeightsTests but over the FILTERED set: dropping collections
// must re-derive weights as `count / filtered-total` so they still sum to 1, rather than
// subtracting-then-leaving-unnormalized (which would reintroduce the weightedRandomPick
// fallthrough bug the unfiltered path already guards against).
//

import OrderedCollections
import XCTest
@testable import NZImageApiLambda

final class FilteredCollectionWeightsTests: XCTestCase {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
func testFilteredWeightsSumToOne() throws {
let exclude: Set<String> = ["Te Papa Collections Online", "Kura Heritage Collections Online"]
let weights = try NZImageApi.filteredCollectionWeights(excluding: exclude)
let total = weights.values.reduce(0, +)
XCTAssertEqual(total, 1.0, accuracy: 0.000_001)
}

func testExcludedCollectionsAreAbsent() throws {
let exclude: Set<String> = ["Te Papa Collections Online", "Kura Heritage Collections Online"]
let weights = try NZImageApi.filteredCollectionWeights(excluding: exclude)
for excluded in exclude {
XCTAssertNil(weights[excluded], "\(excluded) should not appear in the filtered weights")
}
}

func testFilteredWeightsPreserveRemainingCollections() throws {
let exclude: Set<String> = ["Te Papa Collections Online"]
let weights = try NZImageApi.filteredCollectionWeights(excluding: exclude)
XCTAssertEqual(
weights.count,
NZImageApi.collectionImageCounts.count - exclude.count
)
// A large non-excluded collection must still be present with a positive weight, proving
// the remaining proportions were renormalized rather than dropped.
XCTAssertNotNil(weights["Kura Heritage Collections Online"])
for (name, weight) in weights {
XCTAssertGreaterThan(weight, 0, "\(name) has a non-positive weight")
}
}

func testRenormalizationRedistributesProportionally() throws {
// Removing a collection should scale every survivor's weight up by exactly
// 1 / (1 - removedShare), preserving relative proportions among survivors.
let removed = "Te Papa Collections Online"
let unfiltered = NZImageApi.collectionWeights
let removedShare = unfiltered[removed]!
let filtered = try NZImageApi.filteredCollectionWeights(excluding: [removed])

let scale = 1.0 / (1.0 - removedShare)
for (name, filteredWeight) in filtered {
XCTAssertEqual(filteredWeight, unfiltered[name]! * scale, accuracy: 0.000_001)
}
}

func testExcludingEveryCollectionThrows() {
let all = Set(NZImageApi.collectionImageCounts.keys)
XCTAssertThrowsError(try NZImageApi.filteredCollectionWeights(excluding: all)) { error in
XCTAssertTrue(error is NZImageApi.NoEligibleCollectionsError)
}
}

func testEmptyExcludeMatchesUnfilteredWeights() throws {
// An empty exclude set is a degenerate filter: it must reproduce the canonical
// unfiltered weights exactly (same keys, same order, same values).
let filtered = try NZImageApi.filteredCollectionWeights(excluding: [])
XCTAssertEqual(Array(filtered.keys), Array(NZImageApi.collectionWeights.keys))
for (name, weight) in filtered {
XCTAssertEqual(weight, NZImageApi.collectionWeights[name]!, accuracy: 0.000_001)
}
}
}
5 changes: 5 additions & 0 deletions template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,11 @@ Resources:
Properties:
Path: /image
Method: GET
Collections:
Type: HttpApi
Properties:
Path: /collections
Method: GET
Metadata:
BuildMethod: makefile

Expand Down