A modern Swift package for interacting with the Untappd API v4. Built with Swift 6 concurrency features including async/await, actors, and Sendable types for complete thread safety.
- âś… Modern async/await API - No completion handlers, just clean async code
- âś… Swift 6 Concurrency - Full Sendable conformance and actor isolation
- âś… Type-safe - Strongly typed models with Codable support
- âś… Pluggable networking - Bring your own URLSession or custom implementation
- âś… Comprehensive error handling - Typed errors with detailed information
- âś… Compact mode support - Reduce response payload size when needed
- Swift 6.2+
- macOS 13.0+
- Untappd API credentials (client ID and client secret)
Add the following to your Package.swift file:
dependencies: [
.package(url: "https://github.com/coodly/swift-untappd-api.git", from: "1.0.0")
]Or add it through Xcode:
- File → Add Package Dependencies
- Enter the repository URL
- Select version requirements
Register your application at Untappd API to obtain your client ID and client secret.
The library uses a protocol-based approach for networking, allowing you to use URLSession or any custom implementation:
import Foundation
import UntappdAPI
struct URLSessionFetch: NetworkFetch {
func fetch(request: URLRequest) async throws -> (Data, URLResponse) {
try await URLSession.shared.data(for: request)
}
}let fetcher = URLSessionFetch()
let untappd = await Untappd.anonymous(
clientID: "YOUR_CLIENT_ID",
clientSecret: "YOUR_CLIENT_SECRET",
fetch: fetcher
)Search the Untappd database for beers by name:
do {
let beers = try await untappd.search(beer: "Pliny the Elder")
for beer in beers {
print("\(beer.beer.beerName) by \(beer.brewery.breweryName)")
print("Style: \(beer.beer.beerStyle)")
print("ABV: \(beer.beer.beerAbv)%")
print("---")
}
} catch {
print("Search failed: \(error)")
}Retrieve detailed information about a specific beer:
do {
let beerId = 4691
let details = try await untappd.info(for: beerId)
print("Name: \(details.beerName)")
print("Style: \(details.beerStyle)")
print("ABV: \(details.beerAbv)%")
print("Rating: \(details.ratingScore)")
print("Description: \(details.beerDescription)")
print("Brewery: \(details.brewery.breweryName)")
// Access photos if available
if let media = details.media {
print("Photos: \(media.items.count)")
for photo in media.items {
print("Photo URL: \(photo.photo.photoImgLg)")
}
}
} catch {
print("Failed to fetch beer info: \(error)")
}Use compact mode to reduce the response payload size:
let details = try await untappd.info(for: beerId, compact: true)If you have a BeerOverview from search results, you can fetch full details directly:
let searchResults = try await untappd.search(beer: "IPA")
if let firstBeer = searchResults.first {
let details = try await untappd.info(for: firstBeer.beer)
print("Full details: \(details)")
}Returned from search results:
struct BeerItem: Codable, Sendable {
let beer: BeerOverview
let brewery: Brewery
}Lightweight beer information:
struct BeerOverview: Codable, Sendable {
let bid: Int // Beer ID
let beerName: String // Beer name
let beerStyle: String // Style (e.g., "IPA - American")
let beerAbv: Double // Alcohol by volume
}Complete beer information:
struct BeerDetails: Codable, Sendable {
let bid: Int
let beerName: String
let beerStyle: String
let beerDescription: String
let beerAbv: Double
let ratingScore: Double
let brewery: Brewery
let media: MediaContainer? // Optional photos
}Brewery information:
struct Brewery: Codable, Sendable {
let breweryId: Int
let breweryName: String
}The library throws typed UntappdError for different failure scenarios:
enum UntappdError: Error {
case noData // No data received
case network(Error) // Network error (wraps underlying error)
case server(String) // Server error (HTTP status != 200)
case invalidJSON // JSON decoding failed
case unknown // Unknown error
case rateLimitExceeded(limit: Int, remaining: Int, resetsAt: Date?) // Rate limit hit
}All errors conform to LocalizedError, so you can get user-friendly descriptions:
do {
let beers = try await untappd.search(beer: "IPA")
// Process beers...
} catch {
// Simple error display using localized description
print(error.localizedDescription)
}For more granular error handling:
do {
let beers = try await untappd.search(beer: "IPA")
// Process beers...
} catch let error as UntappdError {
switch error {
case .noData:
print("No data received from server")
case .network(let underlying):
print("Network error: \(underlying.localizedDescription)")
case .server(let message):
print("Server error: \(message)")
case .invalidJSON:
print("Failed to decode response")
case .rateLimitExceeded(let limit, let remaining, let resetsAt):
print("Rate limit hit: \(remaining)/\(limit) requests remaining")
if let resetDate = resetsAt {
print("Resets at: \(resetDate)")
}
case .unknown:
print("Unknown error occurred")
}
} catch {
print("Unexpected error: \(error)")
}Enable logging for debugging purposes:
import UntappdAPI
// Implement the Logger protocol
struct ConsoleLogger: Logger {
func log<T>(_ object: T, file: String, function: String, line: Int) {
print("[\(file):\(line)] \(function) - \(object)")
}
}
// Set the logger
Logging.set(logger: ConsoleLogger())You can provide a custom networking implementation for testing or advanced use cases:
actor MockNetworkFetch: NetworkFetch {
private let mockData: Data
init(mockData: Data) {
self.mockData = mockData
}
func fetch(request: URLRequest) async throws -> (Data, URLResponse) {
let response = HTTPURLResponse(
url: request.url!,
statusCode: 200,
httpVersion: nil,
headerFields: nil
)!
return (mockData, response)
}
}
// Use in tests
let mockFetch = MockNetworkFetch(mockData: testJSON)
let untappd = await Untappd.anonymous(
clientID: "test",
clientSecret: "test",
fetch: mockFetch
)The library is built with Swift 6 concurrency in mind:
- All data models conform to
Sendable - Internal state is protected by actors
- No data races possible at compile time
- Safe to use across multiple tasks and actors
// Safe to call from multiple tasks concurrently
await withTaskGroup(of: [BeerItem].self) { group in
group.addTask {
try await untappd.search(beer: "IPA")
}
group.addTask {
try await untappd.search(beer: "Stout")
}
for try await results in group {
print("Found \(results.count) beers")
}
}Untappd enforces API rate limits (typically 100 requests per hour for anonymous access). When you hit the rate limit, the library will throw a UntappdError.rateLimitExceeded error that includes:
- The total rate limit (
limit) - Remaining requests (
remaining) - When the limit resets (
resetsAt)
The library detects rate limit errors but does not implement automatic throttling or retry logic. You should handle rate limiting in your application code, for example:
do {
let beers = try await untappd.search(beer: "IPA")
// Process beers...
} catch UntappdError.rateLimitExceeded(let limit, let remaining, let resetsAt) {
if let resetDate = resetsAt {
let waitTime = resetDate.timeIntervalSinceNow
print("Rate limit exceeded. Waiting \(waitTime) seconds...")
try await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
// Retry the request
}
}
## License
Licensed under the Apache License, Version 2.0. See LICENSE file for details.
Copyright 2019 Coodly LLC
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## Resources
- [Untappd API Documentation](https://untappd.com/api/docs)
- [Swift Concurrency](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/)
- [Swift Package Manager](https://swift.org/package-manager/)