Made Amiibo models Codable and Identifiable, and filters Hashable.

This commit is contained in:
javier committed 2026-10-25 20:20:41 +01:00
1 parent 8fa4d50734
commit a28842eb0a
24 files changed
+352 -190

No files matched your search

+1 -1
View File
@@ -56,7 +56,7 @@ let gameSeries = try await service.getGameSeries()
let lastUpdated = try await service.getLastUpdated() 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 ## Error handling
@@ -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. `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 ## Installation
@@ -76,9 +76,7 @@ do throws(AmiiboServiceError) {
## Caching ## Caching
The library has no built-in cache. The Amiibo API asks regular consumers to cache responses. 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`:
To cache at the HTTP level, give ``AmiiboLiveClient`` a transport backed by a `URLCache`. The cache follows the server's cache headers:
```swift ```swift
import OpenAPIURLSession 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 ## 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 ```swift
import AmiiboService import AmiiboService
@@ -18,13 +18,11 @@ extension DateFormatter {
// MARK: Properties // MARK: Properties
/// An ISO date formatter. /// An ISO date formatter for the `yyyy-MM-dd` format.
///
/// This formatter implements the `yyyy-MM-dd` date format.
static let isoDate: DateFormatter = { static let isoDate: DateFormatter = {
let formatter = 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.locale = .init(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd" formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = .init(secondsFromGMT: 0) formatter.timeZone = .init(secondsFromGMT: 0)
@@ -32,14 +30,13 @@ extension DateFormatter {
return formatter 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. /// Used only to decode the timestamp returned by ``AmiiboService/getLastUpdated()``.
/// 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.
static let isoTimestamp: DateFormatter = { static let isoTimestamp: DateFormatter = {
let formatter = 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.locale = .init(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS" formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS"
formatter.timeZone = .init(secondsFromGMT: 0) formatter.timeZone = .init(secondsFromGMT: 0)
@@ -12,8 +12,8 @@
// //
// ===----------------------------------------------------------------------=== // ===----------------------------------------------------------------------===
/// A protocol that defines filters containing optional `key` and/or `name` values for querying resources. /// A protocol for filters with optional `key` and `name` values.
protocol KeyNameFilter { protocol KeyNameFilter: Hashable {
// MARK: Properties // MARK: Properties
@@ -13,7 +13,7 @@
// ===----------------------------------------------------------------------=== // ===----------------------------------------------------------------------===
/// A protocol that defines models containing a `key` and `name` pair. /// A protocol that defines models containing a `key` and `name` pair.
protocol KeyNameModel: Sendable, Hashable { protocol KeyNameModel: Sendable, Hashable, Codable, Identifiable {
// MARK: Properties // MARK: Properties
@@ -15,15 +15,15 @@
import Foundation import Foundation
import OpenAPIRuntime 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 { struct ISODateTimeTranscoder {
// MARK: Properties // MARK: Properties
/// A formatter to use to decode and encode ISO timestamp dates. /// The ISO timestamp formatter.
private let timestampFormatter: DateFormatter = .isoTimestamp 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 private let dateFormatter: DateFormatter = .isoDate
} }
@@ -34,17 +34,13 @@ extension ISODateTimeTranscoder: DateTranscoder {
// MARK: Functions // MARK: Functions
/// Encodes a date into an ISO timestamp string. /// Encodes a date as 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.
func encode(_ date: Date) throws -> String { func encode(_ date: Date) throws -> String {
timestampFormatter.string(from: date) 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. /// Decodes an ISO date string, trying the timestamp format before the date-only one.
/// - Parameter string: A string to decode. /// - Throws: A `DecodingError` if the string matches neither format.
/// - Returns: A date parsed from the string.
/// - Throws: A `DecodingError` if the string cannot be parsed into a valid date.
func decode(_ string: String) throws -> Date { func decode(_ string: String) throws -> Date {
if let date = timestampFormatter.date(from: string) { if let date = timestampFormatter.date(from: string) {
return date return date
@@ -16,16 +16,16 @@ import Foundation
import OpenAPIRuntime import OpenAPIRuntime
import OpenAPIURLSession 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 { public struct AmiiboLiveClient: Sendable {
// MARK: Constants // 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 = { static let serverURL: URL = {
guard let url = try? Servers.Server1.url() else { 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.") 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 // MARK: Initializers
/// Initializes this client with a transport for performing HTTP operations. /// Initializes this client.
/// - Parameter transport: A transport that performs HTTP operations. Defaults to a `URLSessionTransport` using the shared session. /// - Parameter transport: A transport for HTTP operations. Defaults to a `URLSessionTransport` on the shared session.
public init(transport: any ClientTransport = URLSessionTransport()) { public init(transport: any ClientTransport = URLSessionTransport()) {
self.client = .init( self.client = .init(
serverURL: Self.serverURL, serverURL: Self.serverURL,
@@ -61,9 +61,9 @@ extension AmiiboLiveClient: AmiiboClient {
// MARK: Functions // MARK: Functions
/// Gets a list of amiibo items based on a given filter. /// 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. /// - 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( public func getAmiibos(
by filter: AmiiboFilter by filter: AmiiboFilter
) async throws(AmiiboServiceError) -> [Amiibo] { ) async throws(AmiiboServiceError) -> [Amiibo] {
@@ -71,9 +71,9 @@ extension AmiiboLiveClient: AmiiboClient {
} }
/// Gets a list of amiibo series based on a given filter. /// 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. /// - 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( public func getAmiiboSeries(
by filter: AmiiboSeriesFilter by filter: AmiiboSeriesFilter
) async throws(AmiiboServiceError) -> [AmiiboSeries] { ) async throws(AmiiboServiceError) -> [AmiiboSeries] {
@@ -81,9 +81,9 @@ extension AmiiboLiveClient: AmiiboClient {
} }
/// Gets a list of amiibo types based on a given filter. /// 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. /// - 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( public func getAmiiboTypes(
by filter: AmiiboTypeFilter by filter: AmiiboTypeFilter
) async throws(AmiiboServiceError) -> [AmiiboType] { ) async throws(AmiiboServiceError) -> [AmiiboType] {
@@ -91,9 +91,9 @@ extension AmiiboLiveClient: AmiiboClient {
} }
/// Gets a list of game characters based on a given filter. /// 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. /// - 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( public func getGameCharacters(
by filter: GameCharacterFilter by filter: GameCharacterFilter
) async throws(AmiiboServiceError) -> [GameCharacter] { ) async throws(AmiiboServiceError) -> [GameCharacter] {
@@ -101,9 +101,9 @@ extension AmiiboLiveClient: AmiiboClient {
} }
/// Gets a list of game series based on a given filter. /// 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. /// - 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( public func getGameSeries(
by filter: GameSeriesFilter by filter: GameSeriesFilter
) async throws(AmiiboServiceError) -> [GameSeries] { ) async throws(AmiiboServiceError) -> [GameSeries] {
@@ -112,7 +112,7 @@ extension AmiiboLiveClient: AmiiboClient {
/// Gets the date when the data was last updated. /// Gets the date when the data was last updated.
/// - Returns: A last updated date, decoded as UTC. /// - 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 { public func getLastUpdated() async throws(AmiiboServiceError) -> Date {
try await fetchLastUpdated() try await fetchLastUpdated()
} }
@@ -125,10 +125,7 @@ private extension AmiiboLiveClient {
// MARK: Functions // MARK: Functions
/// Fetches a list of amiibo items based on a given filter. /// Fetches the amiibos matching a 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.
func fetchAmiibos( func fetchAmiibos(
_ filter: AmiiboFilter _ filter: AmiiboFilter
) async throws(AmiiboServiceError) -> [Amiibo] { ) async throws(AmiiboServiceError) -> [Amiibo] {
@@ -174,10 +171,7 @@ private extension AmiiboLiveClient {
} }
} }
/// Fetches a list of amiibo series based on a given filter. /// Fetches the amiibo series matching a 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.
func fetchAmiiboSeries( func fetchAmiiboSeries(
_ filter: AmiiboSeriesFilter _ filter: AmiiboSeriesFilter
) async throws(AmiiboServiceError) -> [AmiiboSeries] { ) async throws(AmiiboServiceError) -> [AmiiboSeries] {
@@ -210,10 +204,7 @@ private extension AmiiboLiveClient {
} }
} }
/// Fetches a list of amiibo types based on a given filter. /// Fetches the amiibo types matching a 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.
func fetchAmiiboTypes( func fetchAmiiboTypes(
_ filter: AmiiboTypeFilter _ filter: AmiiboTypeFilter
) async throws(AmiiboServiceError) -> [AmiiboType] { ) async throws(AmiiboServiceError) -> [AmiiboType] {
@@ -246,10 +237,7 @@ private extension AmiiboLiveClient {
} }
} }
/// Fetches a list of game characters based on a given filter. /// Fetches the game characters matching a 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.
func fetchGameCharacters( func fetchGameCharacters(
_ filter: GameCharacterFilter _ filter: GameCharacterFilter
) async throws(AmiiboServiceError) -> [GameCharacter] { ) async throws(AmiiboServiceError) -> [GameCharacter] {
@@ -282,10 +270,7 @@ private extension AmiiboLiveClient {
} }
} }
/// Fetches a list of game series based on a given filter. /// Fetches the game series matching a 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.
func fetchGameSeries( func fetchGameSeries(
_ filter: GameSeriesFilter _ filter: GameSeriesFilter
) async throws(AmiiboServiceError) -> [GameSeries] { ) async throws(AmiiboServiceError) -> [GameSeries] {
@@ -319,8 +304,6 @@ private extension AmiiboLiveClient {
} }
/// Fetches the date when the data was last updated. /// 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 { func fetchLastUpdated() async throws(AmiiboServiceError) -> Date {
let response = try await perform { let response = try await perform {
try await client.getLastUpdated() 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. /// Performs an API call, mapping any error to an ``AmiiboServiceError``.
/// - 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.
func perform<Output>( func perform<Output>(
_ operation: () async throws -> Output _ operation: () async throws -> Output
) async throws(AmiiboServiceError) -> 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. /// Maps key-name payloads into models sorted by key.
/// - Parameter payloads: A list of payloads to map into models.
/// - Returns: A list of models sorted by their keys in ascending order.
func makeModels<Model: KeyNameModel>(from payloads: [some KeyNamePayload]) -> [Model] { func makeModels<Model: KeyNameModel>(from payloads: [some KeyNamePayload]) -> [Model] {
payloads payloads
.map { Model($0) } .map { Model($0) }
.sorted { $0.key < $1.key } .sorted { $0.key < $1.key }
} }
/// Maps a given error to an ``AmiiboServiceError`` error. /// Maps an error to the corresponding ``AmiiboServiceError``.
/// - Parameter error: An error to map.
/// - Throws: An ``AmiiboServiceError`` error that corresponds to the given error.
func handle(error: any Error) throws(AmiiboServiceError) -> Never { func handle(error: any Error) throws(AmiiboServiceError) -> Never {
switch error { switch error {
case is CancellationError: case is CancellationError:
@@ -22,7 +22,7 @@ public enum AmiiboServiceError: Error {
case cancelled case cancelled
/// The response body could not be decoded into the expected model. /// The response body could not be decoded into the expected model.
case decoding 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 case notAvailable
/// No results were found matching the given filter criteria. /// No results were found matching the given filter criteria.
/// ///
@@ -30,7 +30,7 @@ public enum AmiiboServiceError: Error {
case notFound case notFound
/// The server returned an undocumented HTTP status code. /// The server returned an undocumented HTTP status code.
case undocumented(_ statusCode: Int) 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) case unknown(_ description: String)
} }
@@ -15,7 +15,7 @@
/// A filter for amiibo requests. /// A filter for amiibo requests.
/// ///
/// Values left as `nil` do not restrict the result. /// Values left as `nil` do not restrict the result.
public struct AmiiboFilter: Sendable { public struct AmiiboFilter: Sendable, Hashable {
// MARK: Properties // MARK: Properties
@@ -37,10 +37,10 @@ public struct AmiiboFilter: Sendable {
/// An amiibo series key or name to match, if any. /// An amiibo series key or name to match, if any.
public let series: String? 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? 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? public let showUsage: Bool?
/// The last 8 hexadecimal characters of an identifier to match, if any. Shorter values match as a prefix. /// 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. /// - series: An amiibo series key or name to match, if any.
/// - gameCharacter: A game character 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. /// - 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`. /// - showGames: 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`. /// - showUsage: Whether to include related games and their usages in ``Amiibo/platform``. `nil` means `false`.
public init( public init(
head: String? = nil, head: String? = nil,
tail: String? = nil, tail: String? = nil,
@@ -15,7 +15,7 @@
import Foundation import Foundation
/// A model that represents an amiibo. /// A model that represents an amiibo.
public struct Amiibo: Sendable, Hashable { public struct Amiibo: Sendable, Hashable, Codable, Identifiable {
// MARK: Properties // MARK: Properties
@@ -36,7 +36,7 @@ public struct Amiibo: Sendable, Hashable {
/// The related games of this amiibo, grouped by platform. /// 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? public let platform: Platform?
/// The release dates of this amiibo across different regions. /// The release dates of this amiibo across different regions.
@@ -99,7 +99,12 @@ public struct Amiibo: Sendable, Hashable {
} }
// MARK: Computed // MARK: Computed
/// The ``identifier`` of this amiibo.
public var id: String {
identifier
}
/// The full 16-character hexadecimal identifier, composed of the ``head`` and ``tail``. /// The full 16-character hexadecimal identifier, composed of the ``head`` and ``tail``.
public var identifier: String { public var identifier: String {
head + tail head + tail
@@ -14,7 +14,7 @@
extension Amiibo { extension Amiibo {
/// A model that represents a game related to an amiibo. /// A model that represents a game related to an amiibo.
public struct Game: Sendable, Hashable { public struct Game: Sendable, Hashable, Codable {
// MARK: Properties // MARK: Properties
@@ -13,8 +13,8 @@
// ===----------------------------------------------------------------------=== // ===----------------------------------------------------------------------===
extension Amiibo { extension Amiibo {
/// A model that represents a collection of `Switch`, `Switch 2`, `3DS`, and `Wii U` games related to an amiibo. /// A model that groups the games related to an amiibo by platform.
public struct Platform: Sendable, Hashable { public struct Platform: Sendable, Hashable, Codable {
// MARK: Properties // MARK: Properties
@@ -18,7 +18,7 @@ extension Amiibo {
/// A model that represents the regional release dates of an 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. /// 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 // MARK: Properties
@@ -14,14 +14,14 @@
extension Amiibo { extension Amiibo {
/// A model that represents the usage of an amiibo within a certain game. /// 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 // MARK: Properties
/// A description of how the amiibo is used within the game. /// A description of how the amiibo is used within the game.
public let explanation: String 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 public let isWriteable: Bool
// MARK: Initializers // MARK: Initializers
@@ -37,5 +37,12 @@ public struct AmiiboSeries: KeyNameModel {
self.key = payload.key self.key = payload.key
self.name = payload.name self.name = payload.name
} }
// MARK: Computed
/// The ``key`` of this amiibo series.
public var id: String {
key
}
} }
@@ -37,5 +37,12 @@ public struct AmiiboType: KeyNameModel {
self.key = payload.key self.key = payload.key
self.name = payload.name self.name = payload.name
} }
// MARK: Computed
/// The ``key`` of this amiibo type.
public var id: String {
key
}
} }
@@ -37,5 +37,12 @@ public struct GameCharacter: KeyNameModel {
self.key = payload.key self.key = payload.key
self.name = payload.name self.name = payload.name
} }
// MARK: Computed
/// The ``key`` of this game character.
public var id: String {
key
}
} }
@@ -37,5 +37,12 @@ public struct GameSeries: KeyNameModel {
self.key = payload.key self.key = payload.key
self.name = payload.name self.name = payload.name
} }
// MARK: Computed
/// The ``key`` of this game series.
public var id: String {
key
}
} }
@@ -16,44 +16,44 @@ import Foundation
/// A protocol that defines a client that fetches data from the Amiibo API. /// 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 { public protocol AmiiboClient: Sendable {
// MARK: Functions // MARK: Functions
/// Gets a list of amiibo items based on a given filter. /// 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. /// - 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] func getAmiibos(by filter: AmiiboFilter) async throws(AmiiboServiceError) -> [Amiibo]
/// Gets a list of amiibo series based on a given filter. /// 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. /// - 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] func getAmiiboSeries(by filter: AmiiboSeriesFilter) async throws(AmiiboServiceError) -> [AmiiboSeries]
/// Gets a list of amiibo types based on a given filter. /// 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. /// - 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] func getAmiiboTypes(by filter: AmiiboTypeFilter) async throws(AmiiboServiceError) -> [AmiiboType]
/// Gets a list of game characters based on a given filter. /// 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. /// - 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] func getGameCharacters(by filter: GameCharacterFilter) async throws(AmiiboServiceError) -> [GameCharacter]
/// Gets a list of game series based on a given filter. /// 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. /// - 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] func getGameSeries(by filter: GameSeriesFilter) async throws(AmiiboServiceError) -> [GameSeries]
/// Gets the date when the data was last updated. /// Gets the date when the data was last updated.
/// - Returns: A last updated date, decoded as UTC. /// - 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 func getLastUpdated() async throws(AmiiboServiceError) -> Date
} }
@@ -16,7 +16,7 @@ import Foundation
/// The entry point for fetching data from the Amiibo API. /// 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 { public struct AmiiboService: Sendable {
// MARK: Properties // MARK: Properties
@@ -35,9 +35,9 @@ public struct AmiiboService: Sendable {
// MARK: Functions // MARK: Functions
/// Gets a list of amiibo items based on a given filter. /// 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. /// - 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( public func getAmiibos(
_ filter: AmiiboFilter = .init() _ filter: AmiiboFilter = .init()
) async throws(AmiiboServiceError) -> [Amiibo] { ) async throws(AmiiboServiceError) -> [Amiibo] {
@@ -45,9 +45,9 @@ public struct AmiiboService: Sendable {
} }
/// Gets a list of amiibo series based on a given filter. /// 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. /// - 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( public func getAmiiboSeries(
_ filter: AmiiboSeriesFilter = .init() _ filter: AmiiboSeriesFilter = .init()
) async throws(AmiiboServiceError) -> [AmiiboSeries] { ) async throws(AmiiboServiceError) -> [AmiiboSeries] {
@@ -55,9 +55,9 @@ public struct AmiiboService: Sendable {
} }
/// Gets a list of amiibo types based on a given filter. /// 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. /// - 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( public func getAmiiboTypes(
_ filter: AmiiboTypeFilter = .init() _ filter: AmiiboTypeFilter = .init()
) async throws(AmiiboServiceError) -> [AmiiboType] { ) async throws(AmiiboServiceError) -> [AmiiboType] {
@@ -65,9 +65,9 @@ public struct AmiiboService: Sendable {
} }
/// Gets a list of game characters based on a given filter. /// 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. /// - 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( public func getGameCharacters(
_ filter: GameCharacterFilter = .init() _ filter: GameCharacterFilter = .init()
) async throws(AmiiboServiceError) -> [GameCharacter] { ) async throws(AmiiboServiceError) -> [GameCharacter] {
@@ -75,9 +75,9 @@ public struct AmiiboService: Sendable {
} }
/// Gets a list of game series based on a given filter. /// 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. /// - 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( public func getGameSeries(
_ filter: GameSeriesFilter = .init() _ filter: GameSeriesFilter = .init()
) async throws(AmiiboServiceError) -> [GameSeries] { ) async throws(AmiiboServiceError) -> [GameSeries] {
@@ -86,7 +86,7 @@ public struct AmiiboService: Sendable {
/// Gets the date when the data was last updated. /// Gets the date when the data was last updated.
/// - Returns: A last updated date, decoded as UTC. /// - 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 { public func getLastUpdated() async throws(AmiiboServiceError) -> Date {
try await client.getLastUpdated() try await client.getLastUpdated()
} }
@@ -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)
}
}
@@ -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: Codable>(_ value: Value) throws -> Value {
try JSONDecoder().decode(Value.self, from: JSONEncoder().encode(value))
}
}
@@ -326,71 +326,3 @@ private enum Errors {
.unknown("An underlying error description.") .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")
]
}
@@ -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")
]
}