diff --git a/README.md b/README.md index edf4274..ea2d76e 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ let gameSeries = try await service.getGameSeries() let lastUpdated = try await service.getLastUpdated() ``` -Results are sorted in ascending order by identifier (amiibos) or key (everything else). +Results are sorted in ascending order by identifier (amiibos) or key (everything else). Models are `Hashable`, `Codable`, and `Identifiable`; filters are `Hashable`. ## Error handling diff --git a/Sources/AmiiboService/Catalogs/AmiiboService.docc/Library.md b/Sources/AmiiboService/Catalogs/AmiiboService.docc/Library.md index 726160c..e3144d3 100644 --- a/Sources/AmiiboService/Catalogs/AmiiboService.docc/Library.md +++ b/Sources/AmiiboService/Catalogs/AmiiboService.docc/Library.md @@ -6,7 +6,7 @@ A Swift client for the Amiibo API. `AmiiboService` fetches data from the [Amiibo API](https://www.amiiboapi.org) and returns it as Swift models. -The library does not expose a generated client that maps one-to-one to the REST endpoints. Instead, ``AmiiboService`` offers one method per resource, takes a typed filter, returns sorted models, and throws a single ``AmiiboServiceError`` type. +``AmiiboService`` offers one method per resource: each takes a typed filter, returns sorted models, and throws ``AmiiboServiceError``. ## Installation @@ -76,9 +76,7 @@ do throws(AmiiboServiceError) { ## Caching -The library has no built-in cache. The Amiibo API asks regular consumers to cache responses. - -To cache at the HTTP level, give ``AmiiboLiveClient`` a transport backed by a `URLCache`. The cache follows the server's cache headers: +The library has no built-in cache, and the Amiibo API asks regular consumers to cache responses. To cache at the HTTP level, give ``AmiiboLiveClient`` a transport backed by a `URLCache`: ```swift import OpenAPIURLSession @@ -101,11 +99,11 @@ let service = AmiiboService( ) ``` -You can also store the returned models in your own application storage. +Models are `Codable`, so you can also store them yourself. ## Testing -To test without network access, pass a mock ``AmiiboClient`` to ``AmiiboService/init(client:)``. The mock can return stub data or throw ``AmiiboServiceError`` values. ``AmiiboClient`` refines `Sendable`, so the mock must be `Sendable`, too: +To test without network access, pass a mock ``AmiiboClient`` to ``AmiiboService/init(client:)``. ``AmiiboClient`` refines `Sendable`, so the mock must be `Sendable`, too: ```swift import AmiiboService diff --git a/Sources/AmiiboService/Internal/Extensions/DateFormatter+Properties.swift b/Sources/AmiiboService/Internal/Extensions/DateFormatter+Properties.swift index a39a5bf..a99905d 100644 --- a/Sources/AmiiboService/Internal/Extensions/DateFormatter+Properties.swift +++ b/Sources/AmiiboService/Internal/Extensions/DateFormatter+Properties.swift @@ -18,13 +18,11 @@ extension DateFormatter { // MARK: Properties - /// An ISO date formatter. - /// - /// This formatter implements the `yyyy-MM-dd` date format. + /// An ISO date formatter for the `yyyy-MM-dd` format. static let isoDate: DateFormatter = { let formatter = DateFormatter() - // A fixed-format date requires the POSIX locale, as the user's locale or 12/24-hour setting could otherwise alter the parsing. + // The POSIX locale keeps the user's locale and 12/24-hour setting from altering fixed-format parsing. formatter.locale = .init(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd" formatter.timeZone = .init(secondsFromGMT: 0) @@ -32,14 +30,13 @@ extension DateFormatter { return formatter }() - /// An ISO timestamp formatter. + /// An ISO timestamp formatter for the `yyyy-MM-dd'T'HH:mm:ss.SSSSSS` format. /// - /// This formatter implements the `yyyy-MM-dd'T'HH:mm:ss.SSSSSS` custom date format. - /// Within the context of this library, this formatter is solely used to decode a date formatted as a timestamp that is returned by the ``AmiiboService/getLastUpdated()`` function. + /// Used only to decode the timestamp returned by ``AmiiboService/getLastUpdated()``. static let isoTimestamp: DateFormatter = { let formatter = DateFormatter() - // A fixed-format date requires the POSIX locale, as the user's locale or 12/24-hour setting could otherwise alter the parsing. + // The POSIX locale keeps the user's locale and 12/24-hour setting from altering fixed-format parsing. formatter.locale = .init(identifier: "en_US_POSIX") formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" formatter.timeZone = .init(secondsFromGMT: 0) diff --git a/Sources/AmiiboService/Internal/Protocols/KeyNameFilter.swift b/Sources/AmiiboService/Internal/Protocols/KeyNameFilter.swift index 908ad6c..757b2a1 100644 --- a/Sources/AmiiboService/Internal/Protocols/KeyNameFilter.swift +++ b/Sources/AmiiboService/Internal/Protocols/KeyNameFilter.swift @@ -12,8 +12,8 @@ // // ===----------------------------------------------------------------------=== -/// A protocol that defines filters containing optional `key` and/or `name` values for querying resources. -protocol KeyNameFilter { +/// A protocol for filters with optional `key` and `name` values. +protocol KeyNameFilter: Hashable { // MARK: Properties diff --git a/Sources/AmiiboService/Internal/Protocols/KeyNameModel.swift b/Sources/AmiiboService/Internal/Protocols/KeyNameModel.swift index bee8866..ea207fc 100644 --- a/Sources/AmiiboService/Internal/Protocols/KeyNameModel.swift +++ b/Sources/AmiiboService/Internal/Protocols/KeyNameModel.swift @@ -13,7 +13,7 @@ // ===----------------------------------------------------------------------=== /// A protocol that defines models containing a `key` and `name` pair. -protocol KeyNameModel: Sendable, Hashable { +protocol KeyNameModel: Sendable, Hashable, Codable, Identifiable { // MARK: Properties diff --git a/Sources/AmiiboService/Internal/Transcoders/ISODateTimeTranscoder.swift b/Sources/AmiiboService/Internal/Transcoders/ISODateTimeTranscoder.swift index 28cc7bb..7483d25 100644 --- a/Sources/AmiiboService/Internal/Transcoders/ISODateTimeTranscoder.swift +++ b/Sources/AmiiboService/Internal/Transcoders/ISODateTimeTranscoder.swift @@ -15,15 +15,15 @@ import Foundation import OpenAPIRuntime -/// A type that allows the decoding and encoding of ISO dates, supporting both the `yyyy-MM-dd'T'HH:mm:ss.SSSSSS` timestamp format and the `yyyy-MM-dd` date-only format. +/// A transcoder for ISO dates in the `yyyy-MM-dd'T'HH:mm:ss.SSSSSS` timestamp and `yyyy-MM-dd` date-only formats. struct ISODateTimeTranscoder { // MARK: Properties - /// A formatter to use to decode and encode ISO timestamp dates. + /// The ISO timestamp formatter. private let timestampFormatter: DateFormatter = .isoTimestamp - - /// A formatter to use to decode and encode ISO date-only strings. + + /// The ISO date-only formatter. private let dateFormatter: DateFormatter = .isoDate } @@ -34,17 +34,13 @@ extension ISODateTimeTranscoder: DateTranscoder { // MARK: Functions - /// Encodes a date into an ISO timestamp string. - /// - Parameter date: A date to encode. - /// - Returns: A string representation of the date in `yyyy-MM-dd'T'HH:mm:ss.SSSSSS` format. + /// Encodes a date as an ISO timestamp string. func encode(_ date: Date) throws -> String { timestampFormatter.string(from: date) } - /// Decodes an ISO date string into a date, trying the timestamp format first and falling back to the date-only format. - /// - Parameter string: A string to decode. - /// - Returns: A date parsed from the string. - /// - Throws: A `DecodingError` if the string cannot be parsed into a valid date. + /// Decodes an ISO date string, trying the timestamp format before the date-only one. + /// - Throws: A `DecodingError` if the string matches neither format. func decode(_ string: String) throws -> Date { if let date = timestampFormatter.date(from: string) { return date diff --git a/Sources/AmiiboService/Public/Clients/AmiiboLiveClient.swift b/Sources/AmiiboService/Public/Clients/AmiiboLiveClient.swift index f62a7ff..f1b4a23 100644 --- a/Sources/AmiiboService/Public/Clients/AmiiboLiveClient.swift +++ b/Sources/AmiiboService/Public/Clients/AmiiboLiveClient.swift @@ -16,16 +16,16 @@ import Foundation import OpenAPIRuntime import OpenAPIURLSession -/// A type that implements a live client to the [Amiibo API](https://www.amiiboapi.org) online service. +/// A live client for the [Amiibo API](https://www.amiiboapi.org). /// -/// This client maps any transport error or unsuccessful HTTP response to an ``AmiiboServiceError`` error, and sorts the items of the list responses in ascending order by identifier or key. +/// It maps transport errors and unsuccessful responses to ``AmiiboServiceError``, and sorts list results by identifier or key. public struct AmiiboLiveClient: Sendable { // MARK: Constants - /// The base URL of the live service, resolved once from the server defined in the OpenAPI specification. + /// The base URL of the live service, from the OpenAPI specification. /// - /// The validity of the URL is guaranteed by the bundled `openapi.yaml` specification and enforced by a unit test, so resolution is not expected to fail at runtime. + /// A unit test guards this URL, so resolution is not expected to fail at runtime. static let serverURL: URL = { guard let url = try? Servers.Server1.url() else { fatalError("The server URL defined in the OpenAPI specification could not be resolved. Verify that the 'openapi.yaml' server definition is valid.") @@ -41,8 +41,8 @@ public struct AmiiboLiveClient: Sendable { // MARK: Initializers - /// Initializes this client with a transport for performing HTTP operations. - /// - Parameter transport: A transport that performs HTTP operations. Defaults to a `URLSessionTransport` using the shared session. + /// Initializes this client. + /// - Parameter transport: A transport for HTTP operations. Defaults to a `URLSessionTransport` on the shared session. public init(transport: any ClientTransport = URLSessionTransport()) { self.client = .init( serverURL: Self.serverURL, @@ -61,9 +61,9 @@ extension AmiiboLiveClient: AmiiboClient { // MARK: Functions /// Gets a list of amiibo items based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered amiibo items. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. public func getAmiibos( by filter: AmiiboFilter ) async throws(AmiiboServiceError) -> [Amiibo] { @@ -71,9 +71,9 @@ extension AmiiboLiveClient: AmiiboClient { } /// Gets a list of amiibo series based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered amiibo series. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. public func getAmiiboSeries( by filter: AmiiboSeriesFilter ) async throws(AmiiboServiceError) -> [AmiiboSeries] { @@ -81,9 +81,9 @@ extension AmiiboLiveClient: AmiiboClient { } /// Gets a list of amiibo types based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered amiibo types. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. public func getAmiiboTypes( by filter: AmiiboTypeFilter ) async throws(AmiiboServiceError) -> [AmiiboType] { @@ -91,9 +91,9 @@ extension AmiiboLiveClient: AmiiboClient { } /// Gets a list of game characters based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered game characters. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. public func getGameCharacters( by filter: GameCharacterFilter ) async throws(AmiiboServiceError) -> [GameCharacter] { @@ -101,9 +101,9 @@ extension AmiiboLiveClient: AmiiboClient { } /// Gets a list of game series based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered game series. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. public func getGameSeries( by filter: GameSeriesFilter ) async throws(AmiiboServiceError) -> [GameSeries] { @@ -112,7 +112,7 @@ extension AmiiboLiveClient: AmiiboClient { /// Gets the date when the data was last updated. /// - Returns: A last updated date, decoded as UTC. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. public func getLastUpdated() async throws(AmiiboServiceError) -> Date { try await fetchLastUpdated() } @@ -125,10 +125,7 @@ private extension AmiiboLiveClient { // MARK: Functions - /// Fetches a list of amiibo items based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. - /// - Returns: A list of fetched amiibo items filtered, if requested. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// Fetches the amiibos matching a filter. func fetchAmiibos( _ filter: AmiiboFilter ) async throws(AmiiboServiceError) -> [Amiibo] { @@ -174,10 +171,7 @@ private extension AmiiboLiveClient { } } - /// Fetches a list of amiibo series based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. - /// - Returns: A list of fetched amiibo series filtered, if requested. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// Fetches the amiibo series matching a filter. func fetchAmiiboSeries( _ filter: AmiiboSeriesFilter ) async throws(AmiiboServiceError) -> [AmiiboSeries] { @@ -210,10 +204,7 @@ private extension AmiiboLiveClient { } } - /// Fetches a list of amiibo types based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. - /// - Returns: A list of fetched amiibo types filtered, if requested. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// Fetches the amiibo types matching a filter. func fetchAmiiboTypes( _ filter: AmiiboTypeFilter ) async throws(AmiiboServiceError) -> [AmiiboType] { @@ -246,10 +237,7 @@ private extension AmiiboLiveClient { } } - /// Fetches a list of game characters based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. - /// - Returns: A list of fetched game characters filtered, if requested. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// Fetches the game characters matching a filter. func fetchGameCharacters( _ filter: GameCharacterFilter ) async throws(AmiiboServiceError) -> [GameCharacter] { @@ -282,10 +270,7 @@ private extension AmiiboLiveClient { } } - /// Fetches a list of game series based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. - /// - Returns: A list of fetched game series filtered, if requested. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// Fetches the game series matching a filter. func fetchGameSeries( _ filter: GameSeriesFilter ) async throws(AmiiboServiceError) -> [GameSeries] { @@ -319,8 +304,6 @@ private extension AmiiboLiveClient { } /// Fetches the date when the data was last updated. - /// - Returns: A fetched last updated date, decoded as UTC. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. func fetchLastUpdated() async throws(AmiiboServiceError) -> Date { let response = try await perform { try await client.getLastUpdated() @@ -339,10 +322,7 @@ private extension AmiiboLiveClient { } } - /// Performs an API call, mapping any error thrown by the underlying client to an ``AmiiboServiceError`` error. - /// - Parameter operation: A closure that performs the API call. - /// - Returns: The output of the performed API call. - /// - Throws: An ``AmiiboServiceError`` error in case the API call failed. + /// Performs an API call, mapping any error to an ``AmiiboServiceError``. func perform( _ operation: () async throws -> Output ) async throws(AmiiboServiceError) -> Output { @@ -353,18 +333,14 @@ private extension AmiiboLiveClient { } } - /// Maps a list of key-name payloads into a sorted list of models. - /// - Parameter payloads: A list of payloads to map into models. - /// - Returns: A list of models sorted by their keys in ascending order. + /// Maps key-name payloads into models sorted by key. func makeModels(from payloads: [some KeyNamePayload]) -> [Model] { payloads .map { Model($0) } .sorted { $0.key < $1.key } } - /// Maps a given error to an ``AmiiboServiceError`` error. - /// - Parameter error: An error to map. - /// - Throws: An ``AmiiboServiceError`` error that corresponds to the given error. + /// Maps an error to the corresponding ``AmiiboServiceError``. func handle(error: any Error) throws(AmiiboServiceError) -> Never { switch error { case is CancellationError: diff --git a/Sources/AmiiboService/Public/Errors/AmiiboServiceError.swift b/Sources/AmiiboService/Public/Errors/AmiiboServiceError.swift index 9535187..3461017 100644 --- a/Sources/AmiiboService/Public/Errors/AmiiboServiceError.swift +++ b/Sources/AmiiboService/Public/Errors/AmiiboServiceError.swift @@ -22,7 +22,7 @@ public enum AmiiboServiceError: Error { case cancelled /// The response body could not be decoded into the expected model. case decoding - /// The backend service is currently unreachable due to a network or server issue. + /// The service is unreachable due to a network or server issue. case notAvailable /// No results were found matching the given filter criteria. /// @@ -30,7 +30,7 @@ public enum AmiiboServiceError: Error { case notFound /// The server returned an undocumented HTTP status code. case undocumented(_ statusCode: Int) - /// An unexpected error that does not fall into any other category, with a description of the underlying error. + /// Any other error, with a description of the underlying one. case unknown(_ description: String) } diff --git a/Sources/AmiiboService/Public/Filters/AmiiboFilter.swift b/Sources/AmiiboService/Public/Filters/AmiiboFilter.swift index 40c987d..cebe9fd 100644 --- a/Sources/AmiiboService/Public/Filters/AmiiboFilter.swift +++ b/Sources/AmiiboService/Public/Filters/AmiiboFilter.swift @@ -15,7 +15,7 @@ /// A filter for amiibo requests. /// /// Values left as `nil` do not restrict the result. -public struct AmiiboFilter: Sendable { +public struct AmiiboFilter: Sendable, Hashable { // MARK: Properties @@ -37,10 +37,10 @@ public struct AmiiboFilter: Sendable { /// An amiibo series key or name to match, if any. public let series: String? - /// A flag that indicates whether to include related games in ``Amiibo/platform``. `nil` means `false`. + /// Whether to include related games in ``Amiibo/platform``. `nil` means `false`. public let showGames: Bool? - /// A flag that indicates whether to include related games and their amiibo usages in ``Amiibo/platform``. `nil` means `false`. + /// Whether to include related games and their usages in ``Amiibo/platform``. `nil` means `false`. public let showUsage: Bool? /// The last 8 hexadecimal characters of an identifier to match, if any. Shorter values match as a prefix. @@ -61,8 +61,8 @@ public struct AmiiboFilter: Sendable { /// - series: An amiibo series key or name to match, if any. /// - gameCharacter: A game character key or name to match, if any. /// - gameSeries: A game series key or name to match, if any. - /// - showGames: A flag that indicates whether to include related games in ``Amiibo/platform``. `nil` means `false`. - /// - showUsage: A flag that indicates whether to include related games and their amiibo usages in ``Amiibo/platform``. `nil` means `false`. + /// - showGames: Whether to include related games in ``Amiibo/platform``. `nil` means `false`. + /// - showUsage: Whether to include related games and their usages in ``Amiibo/platform``. `nil` means `false`. public init( head: String? = nil, tail: String? = nil, diff --git a/Sources/AmiiboService/Public/Models/Amiibo.swift b/Sources/AmiiboService/Public/Models/Amiibo.swift index a158943..c6d912b 100644 --- a/Sources/AmiiboService/Public/Models/Amiibo.swift +++ b/Sources/AmiiboService/Public/Models/Amiibo.swift @@ -15,7 +15,7 @@ import Foundation /// A model that represents an amiibo. -public struct Amiibo: Sendable, Hashable { +public struct Amiibo: Sendable, Hashable, Codable, Identifiable { // MARK: Properties @@ -36,7 +36,7 @@ public struct Amiibo: Sendable, Hashable { /// The related games of this amiibo, grouped by platform. /// - /// This value is `nil` unless the request sets ``AmiiboFilter/showGames`` or ``AmiiboFilter/showUsage``, or when the amiibo has no related games. + /// `nil` unless the request sets ``AmiiboFilter/showGames`` or ``AmiiboFilter/showUsage``, or when there are no related games. public let platform: Platform? /// The release dates of this amiibo across different regions. @@ -99,7 +99,12 @@ public struct Amiibo: Sendable, Hashable { } // MARK: Computed - + + /// The ``identifier`` of this amiibo. + public var id: String { + identifier + } + /// The full 16-character hexadecimal identifier, composed of the ``head`` and ``tail``. public var identifier: String { head + tail diff --git a/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Game.swift b/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Game.swift index 84bb9ea..fefef23 100644 --- a/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Game.swift +++ b/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Game.swift @@ -14,7 +14,7 @@ extension Amiibo { /// A model that represents a game related to an amiibo. - public struct Game: Sendable, Hashable { + public struct Game: Sendable, Hashable, Codable { // MARK: Properties diff --git a/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Platform.swift b/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Platform.swift index 22b8af6..6b950d1 100644 --- a/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Platform.swift +++ b/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Platform.swift @@ -13,8 +13,8 @@ // ===----------------------------------------------------------------------=== extension Amiibo { - /// A model that represents a collection of `Switch`, `Switch 2`, `3DS`, and `Wii U` games related to an amiibo. - public struct Platform: Sendable, Hashable { + /// A model that groups the games related to an amiibo by platform. + public struct Platform: Sendable, Hashable, Codable { // MARK: Properties diff --git a/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Release.swift b/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Release.swift index 109c5e2..c29889e 100644 --- a/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Release.swift +++ b/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Release.swift @@ -18,7 +18,7 @@ extension Amiibo { /// A model that represents the regional release dates of an amiibo. /// /// The service provides these dates as date-only values, which are decoded as midnight UTC. - public struct Release: Sendable, Hashable { + public struct Release: Sendable, Hashable, Codable { // MARK: Properties diff --git a/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Usage.swift b/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Usage.swift index ca9ca08..98d4270 100644 --- a/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Usage.swift +++ b/Sources/AmiiboService/Public/Models/Amiibo/Amiibo+Usage.swift @@ -14,14 +14,14 @@ extension Amiibo { /// A model that represents the usage of an amiibo within a certain game. - public struct Usage: Sendable, Hashable { + public struct Usage: Sendable, Hashable, Codable { // MARK: Properties /// A description of how the amiibo is used within the game. public let explanation: String - /// A flag that indicates whether the amiibo can save game data. + /// Whether the amiibo can save game data. public let isWriteable: Bool // MARK: Initializers diff --git a/Sources/AmiiboService/Public/Models/AmiiboSeries.swift b/Sources/AmiiboService/Public/Models/AmiiboSeries.swift index 21986fb..e3fe3d0 100644 --- a/Sources/AmiiboService/Public/Models/AmiiboSeries.swift +++ b/Sources/AmiiboService/Public/Models/AmiiboSeries.swift @@ -37,5 +37,12 @@ public struct AmiiboSeries: KeyNameModel { self.key = payload.key self.name = payload.name } - + + // MARK: Computed + + /// The ``key`` of this amiibo series. + public var id: String { + key + } + } diff --git a/Sources/AmiiboService/Public/Models/AmiiboType.swift b/Sources/AmiiboService/Public/Models/AmiiboType.swift index 8067b4d..355dfa9 100644 --- a/Sources/AmiiboService/Public/Models/AmiiboType.swift +++ b/Sources/AmiiboService/Public/Models/AmiiboType.swift @@ -37,5 +37,12 @@ public struct AmiiboType: KeyNameModel { self.key = payload.key self.name = payload.name } - + + // MARK: Computed + + /// The ``key`` of this amiibo type. + public var id: String { + key + } + } diff --git a/Sources/AmiiboService/Public/Models/GameCharacter.swift b/Sources/AmiiboService/Public/Models/GameCharacter.swift index bcf2229..3126a00 100644 --- a/Sources/AmiiboService/Public/Models/GameCharacter.swift +++ b/Sources/AmiiboService/Public/Models/GameCharacter.swift @@ -37,5 +37,12 @@ public struct GameCharacter: KeyNameModel { self.key = payload.key self.name = payload.name } - + + // MARK: Computed + + /// The ``key`` of this game character. + public var id: String { + key + } + } diff --git a/Sources/AmiiboService/Public/Models/GameSeries.swift b/Sources/AmiiboService/Public/Models/GameSeries.swift index 7150394..f149f37 100644 --- a/Sources/AmiiboService/Public/Models/GameSeries.swift +++ b/Sources/AmiiboService/Public/Models/GameSeries.swift @@ -37,5 +37,12 @@ public struct GameSeries: KeyNameModel { self.key = payload.key self.name = payload.name } - + + // MARK: Computed + + /// The ``key`` of this game series. + public var id: String { + key + } + } diff --git a/Sources/AmiiboService/Public/Protocols/AmiiboClient.swift b/Sources/AmiiboService/Public/Protocols/AmiiboClient.swift index 7a695aa..1d4856b 100644 --- a/Sources/AmiiboService/Public/Protocols/AmiiboClient.swift +++ b/Sources/AmiiboService/Public/Protocols/AmiiboClient.swift @@ -16,44 +16,44 @@ import Foundation /// A protocol that defines a client that fetches data from the Amiibo API. /// -/// ``AmiiboLiveClient`` is the default implementation. Conform to this protocol to provide another one, such as a mock for tests. Conforming types must be `Sendable`. +/// ``AmiiboLiveClient`` is the default implementation; conform to provide another, such as a mock. Conforming types must be `Sendable`. public protocol AmiiboClient: Sendable { // MARK: Functions /// Gets a list of amiibo items based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered amiibo items. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. func getAmiibos(by filter: AmiiboFilter) async throws(AmiiboServiceError) -> [Amiibo] /// Gets a list of amiibo series based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered amiibo series. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. func getAmiiboSeries(by filter: AmiiboSeriesFilter) async throws(AmiiboServiceError) -> [AmiiboSeries] /// Gets a list of amiibo types based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered amiibo types. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. func getAmiiboTypes(by filter: AmiiboTypeFilter) async throws(AmiiboServiceError) -> [AmiiboType] /// Gets a list of game characters based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered game characters. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. func getGameCharacters(by filter: GameCharacterFilter) async throws(AmiiboServiceError) -> [GameCharacter] /// Gets a list of game series based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered game series. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. func getGameSeries(by filter: GameSeriesFilter) async throws(AmiiboServiceError) -> [GameSeries] /// Gets the date when the data was last updated. /// - Returns: A last updated date, decoded as UTC. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. func getLastUpdated() async throws(AmiiboServiceError) -> Date } diff --git a/Sources/AmiiboService/Public/Services/AmiiboService.swift b/Sources/AmiiboService/Public/Services/AmiiboService.swift index e5e843d..c7f5e06 100644 --- a/Sources/AmiiboService/Public/Services/AmiiboService.swift +++ b/Sources/AmiiboService/Public/Services/AmiiboService.swift @@ -16,7 +16,7 @@ import Foundation /// The entry point for fetching data from the Amiibo API. /// -/// This service forwards every call to the ``AmiiboClient`` passed at initialization, which defaults to ``AmiiboLiveClient``. It is `Sendable`, so you can share an instance across concurrency domains. +/// It forwards every call to the ``AmiiboClient`` given at initialization, which defaults to ``AmiiboLiveClient``. Instances are `Sendable`. public struct AmiiboService: Sendable { // MARK: Properties @@ -35,9 +35,9 @@ public struct AmiiboService: Sendable { // MARK: Functions /// Gets a list of amiibo items based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered amiibo items. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. public func getAmiibos( _ filter: AmiiboFilter = .init() ) async throws(AmiiboServiceError) -> [Amiibo] { @@ -45,9 +45,9 @@ public struct AmiiboService: Sendable { } /// Gets a list of amiibo series based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered amiibo series. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. public func getAmiiboSeries( _ filter: AmiiboSeriesFilter = .init() ) async throws(AmiiboServiceError) -> [AmiiboSeries] { @@ -55,9 +55,9 @@ public struct AmiiboService: Sendable { } /// Gets a list of amiibo types based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered amiibo types. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. public func getAmiiboTypes( _ filter: AmiiboTypeFilter = .init() ) async throws(AmiiboServiceError) -> [AmiiboType] { @@ -65,9 +65,9 @@ public struct AmiiboService: Sendable { } /// Gets a list of game characters based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered game characters. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. public func getGameCharacters( _ filter: GameCharacterFilter = .init() ) async throws(AmiiboServiceError) -> [GameCharacter] { @@ -75,9 +75,9 @@ public struct AmiiboService: Sendable { } /// Gets a list of game series based on a given filter. - /// - Parameter filter: A filter to remove unwanted items from the result. + /// - Parameter filter: A filter for the result. /// - Returns: A list of filtered game series. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. public func getGameSeries( _ filter: GameSeriesFilter = .init() ) async throws(AmiiboServiceError) -> [GameSeries] { @@ -86,7 +86,7 @@ public struct AmiiboService: Sendable { /// Gets the date when the data was last updated. /// - Returns: A last updated date, decoded as UTC. - /// - Throws: An ``AmiiboServiceError`` error in case some issue is encountered while generating the result. + /// - Throws: An ``AmiiboServiceError`` if the call fails. public func getLastUpdated() async throws(AmiiboServiceError) -> Date { try await client.getLastUpdated() } diff --git a/Tests/AmiiboService/Cases/Public/Filters/FilterConformanceTests.swift b/Tests/AmiiboService/Cases/Public/Filters/FilterConformanceTests.swift new file mode 100644 index 0000000..f1a79ee --- /dev/null +++ b/Tests/AmiiboService/Cases/Public/Filters/FilterConformanceTests.swift @@ -0,0 +1,61 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the Amiibo Service open source project +// +// Copyright (c) 2026 Röck+Cöde VoF. and the Amiibo Service project authors +// Licensed under Apache license v2.0 +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of Amiibo Service project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------=== + +import AmiiboService +import Testing + +@Suite("Filter conformances") +struct FilterConformanceTests { + + // MARK: Equatable tests + + @Test("compares amiibo filters by value") + func amiiboFilterEquality() { + // GIVEN + // WHEN + // THEN + #expect(AmiiboFilter() == AmiiboFilter()) + #expect(AmiiboFilter(name: "zelda") == AmiiboFilter(name: "zelda")) + #expect(AmiiboFilter(name: "zelda") != AmiiboFilter(name: "mario")) + #expect(AmiiboFilter() != AmiiboFilter(showGames: true)) + } + + @Test("compares key-name filters by value") + func keyNameFilterEquality() { + // GIVEN + // WHEN + // THEN + #expect(AmiiboSeriesFilter() == AmiiboSeriesFilter()) + #expect(AmiiboSeriesFilter(key: "0x01") == AmiiboSeriesFilter(key: "0x01")) + #expect(AmiiboSeriesFilter(key: "0x01") != AmiiboSeriesFilter(name: "0x01")) + #expect(AmiiboTypeFilter(name: "Card") != AmiiboTypeFilter(name: "Figure")) + #expect(GameCharacterFilter(key: "0x0100") != GameCharacterFilter()) + #expect(GameSeriesFilter(name: "Pikmin") == GameSeriesFilter(name: "Pikmin")) + } + + // MARK: Hashable tests + + @Test("hashes equal filters alike") + func filterHashing() { + // GIVEN + let amiiboFilters: Set = [AmiiboFilter(name: "zelda"), AmiiboFilter(name: "zelda"), AmiiboFilter(name: "mario")] + let seriesFilters: Set = [AmiiboSeriesFilter(key: "0x01"), AmiiboSeriesFilter(key: "0x01"), AmiiboSeriesFilter()] + + // WHEN + // THEN + #expect(amiiboFilters.count == 2) + #expect(seriesFilters.count == 2) + } + +} diff --git a/Tests/AmiiboService/Cases/Public/Models/ModelConformanceTests.swift b/Tests/AmiiboService/Cases/Public/Models/ModelConformanceTests.swift new file mode 100644 index 0000000..e859948 --- /dev/null +++ b/Tests/AmiiboService/Cases/Public/Models/ModelConformanceTests.swift @@ -0,0 +1,84 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the Amiibo Service open source project +// +// Copyright (c) 2026 Röck+Cöde VoF. and the Amiibo Service project authors +// Licensed under Apache license v2.0 +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of Amiibo Service project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------=== + +import AmiiboService +import Foundation +import Testing + +@Suite("Model conformances") +struct ModelConformanceTests { + + // MARK: Identifiable tests + + @Test("identifies an amiibo by its identifier") + func amiiboID() { + // GIVEN + let amiibos = Fixtures.amiibos + + // WHEN + // THEN + #expect(amiibos.map(\.id) == amiibos.map(\.identifier)) + } + + @Test("identifies key-name models by their keys") + func keyNameIDs() { + // GIVEN + // WHEN + // THEN + #expect(Fixtures.amiiboSeries.map(\.id) == Fixtures.amiiboSeries.map(\.key)) + #expect(Fixtures.amiiboTypes.map(\.id) == Fixtures.amiiboTypes.map(\.key)) + #expect(Fixtures.gameCharacters.map(\.id) == Fixtures.gameCharacters.map(\.key)) + #expect(Fixtures.gameSeries.map(\.id) == Fixtures.gameSeries.map(\.key)) + } + + // MARK: Codable tests + + @Test("round-trips amiibos through JSON") + func amiibosRoundTrip() throws { + // GIVEN + let amiibos = Fixtures.amiibos + + // WHEN + let decoded = try roundTrip(amiibos) + + // THEN + #expect(decoded == amiibos) + #expect(decoded.first?.platform?.switch.first?.usages?.isEmpty == false) + } + + @Test("round-trips key-name models through JSON") + func keyNameRoundTrip() throws { + // GIVEN + // WHEN + // THEN + #expect(try roundTrip(Fixtures.amiiboSeries) == Fixtures.amiiboSeries) + #expect(try roundTrip(Fixtures.amiiboTypes) == Fixtures.amiiboTypes) + #expect(try roundTrip(Fixtures.gameCharacters) == Fixtures.gameCharacters) + #expect(try roundTrip(Fixtures.gameSeries) == Fixtures.gameSeries) + } + +} + +// MARK: - Helpers + +private extension ModelConformanceTests { + + // MARK: Functions + + /// Encodes a value to JSON and decodes it back. + func roundTrip(_ value: Value) throws -> Value { + try JSONDecoder().decode(Value.self, from: JSONEncoder().encode(value)) + } + +} diff --git a/Tests/AmiiboService/Cases/Public/Services/AmiiboServiceMockTests.swift b/Tests/AmiiboService/Cases/Public/Services/AmiiboServiceMockTests.swift index e1a4373..d7717eb 100644 --- a/Tests/AmiiboService/Cases/Public/Services/AmiiboServiceMockTests.swift +++ b/Tests/AmiiboService/Cases/Public/Services/AmiiboServiceMockTests.swift @@ -326,71 +326,3 @@ private enum Errors { .unknown("An underlying error description.") ] } - -private enum Fixtures { - /// Amiibo items; the first one has release dates and related games with usages. - static let amiibos: [Amiibo] = [ - .init( - head: "01000000", - tail: "00040002", - name: "Zelda", - type: "Figure", - series: "Super Smash Bros.", - gameCharacter: "Zelda", - gameSeries: "The Legend of Zelda", - image: "https://raw.githubusercontent.com/N3evin/AmiiboAPI/master/images/icon_01000000-00040002.png", - release: .init( - america: Date(timeIntervalSince1970: 1_416_441_600), - australia: Date(timeIntervalSince1970: 1_417_564_800), - europe: Date(timeIntervalSince1970: 1_417_132_800), - japan: Date(timeIntervalSince1970: 1_417_564_800) - ), - platform: .init( - switch: [ - .init( - identifiers: ["01007EF00011E000"], - name: "The Legend of Zelda: Breath of the Wild", - usages: [.init(explanation: "Spawns a random selection of items.", isWriteable: false)] - ) - ], - wiiU: [ - .init( - identifiers: ["0005000010145000"], - name: "Super Smash Bros. for Wii U", - usages: [.init(explanation: "Unlocks a figure player.", isWriteable: true)] - ) - ] - ) - ), - .init( - head: "00000000", - tail: "00000002", - name: "Mario", - type: "Figure", - series: "Super Smash Bros.", - gameCharacter: "Mario", - gameSeries: "Super Mario", - image: "https://raw.githubusercontent.com/N3evin/AmiiboAPI/master/images/icon_00000000-00000002.png" - ) - ] - /// Amiibo series. - static let amiiboSeries: [AmiiboSeries] = [ - .init(key: "0x00", name: "Super Smash Bros."), - .init(key: "0x01", name: "Super Mario") - ] - /// Amiibo types. - static let amiiboTypes: [AmiiboType] = [ - .init(key: "0x00", name: "Figure"), - .init(key: "0x01", name: "Card") - ] - /// Game characters. - static let gameCharacters: [GameCharacter] = [ - .init(key: "0x0000", name: "Mario"), - .init(key: "0x0100", name: "Zelda") - ] - /// Game series. - static let gameSeries: [GameSeries] = [ - .init(key: "0x000", name: "Super Mario"), - .init(key: "0x010", name: "The Legend of Zelda") - ] -} diff --git a/Tests/AmiiboService/Helpers/Fixtures/Fixtures.swift b/Tests/AmiiboService/Helpers/Fixtures/Fixtures.swift new file mode 100644 index 0000000..99f75b9 --- /dev/null +++ b/Tests/AmiiboService/Helpers/Fixtures/Fixtures.swift @@ -0,0 +1,85 @@ +// ===----------------------------------------------------------------------=== +// +// This source file is part of the Amiibo Service open source project +// +// Copyright (c) 2026 Röck+Cöde VoF. and the Amiibo Service project authors +// Licensed under Apache license v2.0 +// +// See LICENSE for license information +// See CONTRIBUTORS for the list of Amiibo Service project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +// ===----------------------------------------------------------------------=== + +import AmiiboService +import Foundation + +/// Ready-made models for testing purposes. +enum Fixtures { + /// Amiibo items; the first one has release dates and related games with usages. + static let amiibos: [Amiibo] = [ + .init( + head: "01000000", + tail: "00040002", + name: "Zelda", + type: "Figure", + series: "Super Smash Bros.", + gameCharacter: "Zelda", + gameSeries: "The Legend of Zelda", + image: "https://raw.githubusercontent.com/N3evin/AmiiboAPI/master/images/icon_01000000-00040002.png", + release: .init( + america: Date(timeIntervalSince1970: 1_416_441_600), + australia: Date(timeIntervalSince1970: 1_417_564_800), + europe: Date(timeIntervalSince1970: 1_417_132_800), + japan: Date(timeIntervalSince1970: 1_417_564_800) + ), + platform: .init( + switch: [ + .init( + identifiers: ["01007EF00011E000"], + name: "The Legend of Zelda: Breath of the Wild", + usages: [.init(explanation: "Spawns a random selection of items.", isWriteable: false)] + ) + ], + wiiU: [ + .init( + identifiers: ["0005000010145000"], + name: "Super Smash Bros. for Wii U", + usages: [.init(explanation: "Unlocks a figure player.", isWriteable: true)] + ) + ] + ) + ), + .init( + head: "00000000", + tail: "00000002", + name: "Mario", + type: "Figure", + series: "Super Smash Bros.", + gameCharacter: "Mario", + gameSeries: "Super Mario", + image: "https://raw.githubusercontent.com/N3evin/AmiiboAPI/master/images/icon_00000000-00000002.png" + ) + ] + /// Amiibo series. + static let amiiboSeries: [AmiiboSeries] = [ + .init(key: "0x00", name: "Super Smash Bros."), + .init(key: "0x01", name: "Super Mario") + ] + /// Amiibo types. + static let amiiboTypes: [AmiiboType] = [ + .init(key: "0x00", name: "Figure"), + .init(key: "0x01", name: "Card") + ] + /// Game characters. + static let gameCharacters: [GameCharacter] = [ + .init(key: "0x0000", name: "Mario"), + .init(key: "0x0100", name: "Zelda") + ] + /// Game series. + static let gameSeries: [GameSeries] = [ + .init(key: "0x000", name: "Super Mario"), + .init(key: "0x010", name: "The Legend of Zelda") + ] +}