Skip to content

Commit 59a0eed

Browse files
authored
Merge pull request #99 from rryam/agent/language-tag-endpoint
Add best supported language endpoint
2 parents 5783cfa + 6ac6c51 commit 59a0eed

3 files changed

Lines changed: 186 additions & 0 deletions

File tree

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//
2+
// MCatalog+Language.swift
3+
// MusadoraKit
4+
//
5+
// Created by Claude on 05/08/26.
6+
//
7+
8+
import Foundation
9+
10+
/// `LanguageTagResponse` is a struct that decodes the response from the best supported language endpoint.
11+
///
12+
/// This structure corresponds to the response you would receive when asking the Apple Music API
13+
/// for the best supported language tag of a storefront.
14+
///
15+
/// - Note: Apple's documentation refers to this object as `LangageTagResponse` (sic);
16+
/// MusadoraKit spells it correctly.
17+
struct LanguageTagResponse: Decodable {
18+
/// The results container returned by the endpoint.
19+
let results: Results
20+
21+
/// The container holding the resolved language tag.
22+
struct Results: Decodable {
23+
/// The best language tag supported by the storefront, as an RFC 4646 language tag.
24+
let tag: String
25+
}
26+
}
27+
28+
public extension MCatalog {
29+
/// Fetches the best supported language tag for a storefront of Apple Music.
30+
///
31+
/// Pass the language tags your app accepts, in order of preference, and the Apple Music API
32+
/// resolves them against the storefront's supported language tags server-side. The returned
33+
/// tag is the best match, ready to use as the localization (`l`) parameter in subsequent
34+
/// catalog requests.
35+
///
36+
/// Example usage:
37+
///
38+
/// ```swift
39+
/// do {
40+
/// let languageTag = try await MCatalog.bestLanguageTag(for: "in", accepting: ["hi", "en-GB"])
41+
/// print(languageTag) // "hi"
42+
/// } catch {
43+
/// print("Failed to fetch the best language tag: \(error)")
44+
/// }
45+
/// ```
46+
///
47+
/// In the above example, "in" is the identifier for the India storefront, and the accepted
48+
/// languages prefer Hindi over British English.
49+
///
50+
/// - Parameters:
51+
/// - storefront: The identifier for the storefront you want to resolve the language for. This is usually a country code.
52+
/// - languages: The language tags your app accepts, in order of preference.
53+
/// - localization: An optional localization to use for the request. Defaults to `nil`.
54+
/// - Returns: The best language tag supported by the storefront, as an RFC 4646 language tag.
55+
/// - Throws: `MusadoraKitError.languageTagsMissing` if `languages` is empty, or an error if there was a problem with the network request or decoding the response.
56+
static func bestLanguageTag(for storefront: String, accepting languages: [String], localization: String? = nil) async throws -> String {
57+
let url = try bestLanguageTagURL(storefront: storefront, languages: languages, localization: localization)
58+
59+
let request = MusicDataRequest(urlRequest: .init(url: url))
60+
let response = try await request.response()
61+
let languageTag = try JSONDecoder().decode(LanguageTagResponse.self, from: response.data)
62+
63+
return languageTag.results.tag
64+
}
65+
66+
/// Fetches the best supported language tag for a storefront of Apple Music,
67+
/// based on the user's preferred languages.
68+
///
69+
/// This convenience method uses `Locale.preferredLanguages` — the user's preferred
70+
/// languages, in order — as the accepted language tags.
71+
///
72+
/// Example usage:
73+
///
74+
/// ```swift
75+
/// do {
76+
/// let languageTag = try await MCatalog.bestLanguageTag(for: "in")
77+
/// print(languageTag)
78+
/// } catch {
79+
/// print("Failed to fetch the best language tag: \(error)")
80+
/// }
81+
/// ```
82+
///
83+
/// - Parameter storefront: The identifier for the storefront you want to resolve the language for. This is usually a country code.
84+
/// - Returns: The best language tag supported by the storefront, as an RFC 4646 language tag.
85+
/// - Throws: `MusadoraKitError.languageTagsMissing` if `Locale.preferredLanguages` is empty, or an error if there was a problem with the network request or decoding the response.
86+
static func bestLanguageTag(for storefront: String) async throws -> String {
87+
try await bestLanguageTag(for: storefront, accepting: Locale.preferredLanguages)
88+
}
89+
}
90+
91+
extension MCatalog {
92+
/// Returns the URL for fetching the best supported language tag for the specified storefront.
93+
///
94+
/// - Parameters:
95+
/// - storefront: The identifier for the storefront.
96+
/// - languages: The language tags to accept, in order of preference.
97+
/// - localization: An optional localization to use for the request.
98+
///
99+
/// - Returns: The URL for fetching the best supported language tag for the specified storefront.
100+
internal static func bestLanguageTagURL(storefront: String, languages: [String], localization: String?) throws -> URL {
101+
guard !languages.isEmpty else {
102+
throw MusadoraKitError.languageTagsMissing
103+
}
104+
105+
var urlComponents = AppleMusicURLComponents()
106+
urlComponents.path = "language/\(storefront)/tag"
107+
108+
var queryItems = [URLQueryItem(name: "acceptLanguage", value: languages.joined(separator: ","))]
109+
110+
if let localization, !localization.isEmpty {
111+
queryItems.append(URLQueryItem(name: "l", value: localization))
112+
}
113+
114+
urlComponents.queryItems = queryItems
115+
116+
guard let url = urlComponents.url else {
117+
throw URLError(.badURL)
118+
}
119+
120+
return url
121+
}
122+
}

Sources/MusadoraKit/Models/MusadoraKitError.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ public enum MusadoraKitError: Error, Equatable {
3030
/// One or more IDs must be specified for the operation.
3131
case idMissing
3232

33+
/// One or more language tags must be specified for the operation.
34+
case languageTagsMissing
35+
3336
/// The platform does not support this operation.
3437
case platformNotSupported
3538

@@ -93,6 +96,8 @@ extension MusadoraKitError: CustomStringConvertible {
9396
return "A monthly summary period could not be determined from the provided date context."
9497
case .idMissing:
9598
return "One or more IDs must be specified for the operation."
99+
case .languageTagsMissing:
100+
return "One or more language tags must be specified for the operation."
96101
case .platformNotSupported:
97102
return "This operation is only available on iOS."
98103
case let .ratingNotFound(id):
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//
2+
// LanguageTagEndpointTests.swift
3+
// MusadoraKitTests
4+
//
5+
// Created by Claude on 05/08/26.
6+
//
7+
8+
import Foundation
9+
@testable import MusadoraKit
10+
import Testing
11+
12+
@Suite
13+
struct LanguageTagEndpointTests {
14+
@Test
15+
func bestLanguageTagEndpointForStorefront() throws {
16+
let url = try MCatalog.bestLanguageTagURL(storefront: "in", languages: ["hi", "en-GB"], localization: nil)
17+
expectEndpoint(url, equals: "https://api.music.apple.com/v1/language/in/tag?acceptLanguage=hi,en-GB")
18+
}
19+
20+
@Test
21+
func bestLanguageTagEndpointWithLocalization() throws {
22+
let url = try MCatalog.bestLanguageTagURL(storefront: "in", languages: ["hi", "en-GB"], localization: "hi")
23+
expectEndpoint(url, equals: "https://api.music.apple.com/v1/language/in/tag?acceptLanguage=hi,en-GB&l=hi")
24+
}
25+
26+
@Test
27+
func bestLanguageTagEndpointForSingleLanguage() throws {
28+
let url = try MCatalog.bestLanguageTagURL(storefront: "jp", languages: ["ja"], localization: nil)
29+
expectEndpoint(url, equals: "https://api.music.apple.com/v1/language/jp/tag?acceptLanguage=ja")
30+
}
31+
32+
@Test
33+
func bestLanguageTagEndpointIgnoresEmptyLocalization() throws {
34+
let url = try MCatalog.bestLanguageTagURL(storefront: "us", languages: ["en-US"], localization: "")
35+
expectEndpoint(url, equals: "https://api.music.apple.com/v1/language/us/tag?acceptLanguage=en-US")
36+
}
37+
38+
@Test
39+
func bestLanguageTagEndpointEmptyLanguagesThrows() {
40+
#expect(throws: MusadoraKitError.languageTagsMissing) {
41+
_ = try MCatalog.bestLanguageTagURL(storefront: "in", languages: [], localization: nil)
42+
}
43+
}
44+
45+
@Test
46+
func decodingLanguageTagResponse() throws {
47+
let json = Data(#"{"results":{"tag":"en-GB"}}"#.utf8)
48+
let response = try JSONDecoder().decode(LanguageTagResponse.self, from: json)
49+
#expect(response.results.tag == "en-GB")
50+
}
51+
52+
@Test
53+
func decodingLanguageTagResponseWithoutTagThrows() {
54+
let json = Data(#"{"results":{}}"#.utf8)
55+
#expect(throws: Error.self) {
56+
_ = try JSONDecoder().decode(LanguageTagResponse.self, from: json)
57+
}
58+
}
59+
}

0 commit comments

Comments
 (0)