From ios-from-web-guide
MANDATORY for API client and Services work. Use before writing APIClient, any file under Services/, or any networking code.
How this skill is triggered — by the user, by Claude, or both
Slash command
/ios-from-web-guide:ios-api-client-foundationThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
1. **One singleton: `APIClient.shared`.** Never instantiate `APIClient()` in a view or view model — inject the shared instance or mock it in tests.
APIClient.shared. Never instantiate APIClient() in a view or view model — inject the shared instance or mock it in tests.async/await throughout — no closure-based callback variants, no Combine Publisher returns. Every method is async throws.DataResponse<T>, PaginatedResponse<T>, or EmptyResponse. Raw JSON dictionaries are a code smell.JSONEncoder uses .convertToSnakeCase and JSONDecoder uses .convertFromSnakeCase. Phoenix/Rails/Django all expect snake_case on the wire; camelCase on wire is a bug.KeychainService.getToken() on every authenticated request. The caller never touches Authorization headers.AppState.handleUnauthorized() on every 401, which logs the user out. Do not add per-call 401 handling.Configuration.apiBaseURL, which reads from Info.plist (fed by xcconfig). Never hardcode "http://..." in a service method.upload(path:imageData:filename:). Do not hand-roll boundary handling in feature code.APIClient.swift or Services/Auth*.swift.extension APIClient).struct DataResponse<T: Decodable>: Decodable { let data: T }
struct PaginatedResponse<T: Decodable>: Decodable {
let data: [T]
let cursor: String?
}
struct EmptyResponse: Decodable {}
Every endpoint returns one of these. Example:
// GET /posts/123 → { "data": { "id": 123, "title": "..." } }
let response: DataResponse<Post> = try await APIClient.shared.get(path: "/posts/123")
let post = response.data
@MainActor
final class APIClient: APIClientProtocol {
static let shared = APIClient()
func get<T: Decodable>(path: String) async throws -> T
func post<T: Decodable, B: Encodable>(path: String, body: B) async throws -> T
func post<T: Decodable>(path: String) async throws -> T
func put<T: Decodable, B: Encodable>(path: String, body: B) async throws -> T
func delete<T: Decodable>(path: String) async throws -> T
func upload<T: Decodable>(path: String, imageData: Data, filename: String, mimeType: String = "image/jpeg") async throws -> T
}
APIClientProtocol (see templates/APIClientProtocol.swift) is the seam ViewModels inject for testability; templates/MockAPIClient.swift is the matching test double.
Put new endpoints in a feature-specific extension file, not in the body of APIClient:
// Services/APIClient+Posts.swift
extension APIClient {
func fetchFeed(cursor: String? = nil) async throws -> PaginatedResponse<Post> {
let query = cursor.map { "?cursor=\($0)" } ?? ""
return try await get(path: "/feed\(query)")
}
func toggleLike(postId: Int, liked: Bool) async throws -> EmptyResponse {
if liked {
return try await delete(path: "/posts/\(postId)/like")
} else {
return try await post(path: "/posts/\(postId)/like")
}
}
}
private let encoder: JSONEncoder = {
let e = JSONEncoder()
e.keyEncodingStrategy = .convertToSnakeCase
e.dateEncodingStrategy = .iso8601
return e
}()
private let decoder: JSONDecoder = {
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
d.dateDecodingStrategy = .iso8601
return d
}()
// Inside the shared request dispatch:
if httpResponse.statusCode == 401 {
await AppState.shared.handleUnauthorized()
throw APIError.unauthorized
}
AppState.handleUnauthorized() clears the keychain token and flips isAuthenticated = false. SwiftUI then routes to the login screen automatically.
convertToSnakeCase on the encoderSymptom: Phoenix replies with {"errors": {"first_name": ["can't be blank"]}} even though you sent {"firstName": "Joe"}.
Fix: encoder.keyEncodingStrategy = .convertToSnakeCase. Same for the decoder.
Symptom: Works on simulator, fails on device — or ships prod pointing at localhost.
Fix: Read from Configuration.apiBaseURL, which pulls from Info.plist, which is fed by Config.xcconfig (so Debug and Release can differ).
APIClient() in a testSymptom: Tests hit the real network.
Fix: APIClient.shared is the only instance. For tests, inject APIClientProtocol into view models and use MockAPIClient (both provided as templates).
Authorization header in feature codeSymptom: A view model pulls the token from Keychain and sets the header on a one-off URLRequest.
Fix: Move that call through APIClient. Bearer injection is centralized — feature code should never touch auth headers.
See <plugin-root>/templates/APIClient.swift for the full canonical implementation including envelopes, encoder/decoder, bearer injection, 401 handling, and multipart upload. Copy it into Services/APIClient.swift on day 1.
Guides collaborative design exploration before implementation: explores context, asks clarifying questions, proposes approaches, and writes a design doc for user approval.
Creates structured, bite-sized implementation plans from specs or requirements before writing code. Useful for breaking down multi-step tasks into testable steps with file structure and task boundaries.
Resolves in-progress git merge or rebase conflicts by analyzing history, understanding intent, and preserving both changes where possible. Runs automated checks after resolution.
npx claudepluginhub j-morgan6/ios-from-web-guide --plugin ios-from-web-guide