Compare commits

...
6 Commits
6 changed files with 132 additions and 39 deletions
+23 -13
View File
@@ -7,23 +7,31 @@
import Foundation import Foundation
struct Client { /// Every `Request` must pass through this `Client`.
/// Manages merging `Request` and default headers as well as creating the `URLRequest`.
/// Provides its own `JSONDecoder` and `JSONEncoder` with a PascalCase `CodingKey` strategy.
public struct Client {
// MARK: - Properties // MARK: - Properties
private let server: String
private let session: URLSession private let session: URLSession
private let defaultHeaders: [String:String] private let defaultHeaders: [String:String]
private let server: String
private let decoder: JSONDecoder = .init() private let decoder: JSONDecoder = .init()
private let encoder: JSONEncoder = .init() private let encoder: JSONEncoder = .init()
// MARK: - Functions // MARK: - Functions
init(server: String, session: URLSession = .shared, headers: [String:String] = [:]) { public init(server: String, session: URLSession = .shared, headers: [String:String] = [:]) {
self.server = server self.server = server
self.session = session self.session = session
self.defaultHeaders = headers self.defaultHeaders = headers
decoder.keyDecodingStrategy = .convertFromPascalCase
} }
/// When all a client needs is the HTTP status code of an endpoint, this function should be used.
/// - Parameter request: Any Emby or Jellyfin `Request` conforming to the `Request` protocol.
/// - Throws:`RequestError.invalidResponse` if the response could not be coerced into a `HTTPURLResponse`.
/// - Returns: The HTTP status code.
func statusRequest(_ request: any Request) async throws -> Int { func statusRequest(_ request: any Request) async throws -> Int {
do {
let urlRequest = try createRequestObject(for: request) let urlRequest = try createRequestObject(for: request)
let (_, response) = try await session.data(for: urlRequest) let (_, response) = try await session.data(for: urlRequest)
guard let response = response as? HTTPURLResponse else { guard let response = response as? HTTPURLResponse else {
@@ -31,25 +39,27 @@ struct Client {
} }
return response.statusCode return response.statusCode
} catch {
throw RequestError.unknown(message: error.localizedDescription)
}
} }
/// Sends a request to an Emby or Jellyfin server, and builds a model object from that response.
/// - Parameter request: Any Emby or Jellyfin `Request` conforming to the `Request` protocol.
/// - Throws:`RequestError.invalidResponse` if the response could not be coerced into a `HTTPURLResponse`.
/// - Returns: A `Codable` model object whose type is infered from the receiving variable.
func dataRequest<T>(_ request: any Request) async throws -> T? where T: Codable { func dataRequest<T>(_ request: any Request) async throws -> T? where T: Codable {
do {
let urlRequest = try createRequestObject(for: request) let urlRequest = try createRequestObject(for: request)
let (data, response) = try await session.data(for: urlRequest) let (data, response) = try await session.data(for: urlRequest)
guard let response = response as? HTTPURLResponse else { guard response is HTTPURLResponse else {
throw RequestError.invalidResponse throw RequestError.invalidResponse
} }
return try decoder.decode(T.self, from: data) return try decoder.decode(T.self, from: data)
} catch {
throw RequestError.unknown(message: error.localizedDescription)
}
} }
/// Verifies and creates a `URLRequest` from information provided by the `Request`.
/// Merges the `Request` headers, if any, with the default headers.
/// - Parameter request: Any Emby or Jellyfin `Request` conforming to the `Request` protocol.
/// - Throws: `RequestError.badURL` if unable to create a `URL` from the string.
/// - Returns: A fully formed `URLRequest` ready to send.
private func createRequestObject(for request: Request) throws -> URLRequest { private func createRequestObject(for request: Request) throws -> URLRequest {
guard let url = URL(string: "\(server)\(request.endpoint)") else { guard let url = URL(string: "\(server)\(request.endpoint)") else {
throw RequestError.badURL throw RequestError.badURL
@@ -60,7 +70,7 @@ struct Client {
} }
var urlRequest = URLRequest(url: url) var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method urlRequest.httpMethod = request.method.rawValue
urlRequest.allHTTPHeaderFields = headers urlRequest.allHTTPHeaderFields = headers
return urlRequest return urlRequest
} }
@@ -0,0 +1,15 @@
//
// RequestMethod.swift
// JellyKit
//
// Created by Alex Loren on 10/19/26.
//
/// What method should be used when sending a `Request` to Emby or Jellyfin.
/// Usage of this enum prevents any chance of misspelling the method.
public enum RequestMethod: String {
case get = "GET"
case head = "HEAD"
case post = "POST"
case delete = "DELETE"
}
@@ -0,0 +1,39 @@
//
// PascalCaseKey+KeyDecodingStrategy.swift
// JellyKit
//
// Created by Alex Loren on 10/19/26.
//
import Foundation
extension JSONDecoder.KeyDecodingStrategy {
// MARK: - Computed Properties
/// A key decoding strategy that converts pascal-case keys to camel-case keys.
static var convertFromPascalCase: Self {
.custom { codingPath in
let key = codingPath.last!.stringValue
let converted = key.prefix(1).lowercased() + key.dropFirst()
return PascalCaseKey(stringValue: converted)
}
}
}
/// Simply a reusable `CodingKey` for converting PascalCase to camelCase.
private struct PascalCaseKey: CodingKey {
// MARK: - Properties
let stringValue: String
let intValue: Int?
// MARK: - Functions
init(stringValue: String) {
self.stringValue = stringValue
intValue = nil
}
init?(intValue: Int) {
stringValue = String(intValue)
self.intValue = intValue
}
}
+18 -12
View File
@@ -5,18 +5,24 @@
// Created by Alex Loren on 10/13/26. // Created by Alex Loren on 10/13/26.
// //
/// Contains the information of any `User` that is reported back by Emby or Jellyfin.
/// This model does not currently contain all data reported by the server.
/// Properties will become available as needed, not prior.
struct User: Codable { struct User: Codable {
var id: String // MARK: - Properties
var serverId: String
var name: String
var hasPassword: Bool
var primaryImageTag: String
enum CodingKeys: String, CodingKey { /// A server generated unique Id for the `User`.
case id = "Id" var id: String
case serverId = "ServerId"
case name = "Name" /// The `User` belongs to the server with this Id.
case hasPassword = "HasPassword" var serverId: String
case primaryImageTag = "PrimaryImageTag"
} /// A `User` or server administer given name
var name: String
/// If `true` the `User` must be authenticated before making requests on their behalf.
var hasPassword: Bool
/// If not `nil` a request can be made to retreive the profile image for the `User`.
var primaryImageTag: String?
} }
+10 -3
View File
@@ -5,11 +5,18 @@
// Created by Alex Loren on 10/13/26. // Created by Alex Loren on 10/13/26.
// //
import Foundation /// The base for any `Request` made by JellyKit
protocol Request { protocol Request {
// MARK: - Properties // MARK: - Properties
/// This must begin with a forward slash.
/// Points to the resource on the Emby or Jellyfin server.
var endpoint: String { get set } var endpoint: String { get set }
var method: String { get set }
/// What should this request be sent as?
var method: RequestMethod { get set }
/// Any additional headers that are specific to *this* request.
/// Headers needed for every reqeust should be set at `Client` creation.
var headers: [String:String]? { get set } var headers: [String:String]? { get set }
} }
@@ -0,0 +1,16 @@
//
// PingRequest.swift
// JellyKit
//
// Created by Alex Loren on 10/19/26.
//
/// Sends a ping to the Emby or Jellyfin server.
/// Should be used exclusively with the `Client.statusRequest` function.
/// The server will respond with a code of 200 and a body string when successful.
struct PingRequest: Request {
// MARK: - Properties
var endpoint: String = "/System/Ping"
var method: RequestMethod = .get
var headers: [String : String]? = nil
}