Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 46 additions & 11 deletions Packages/SparkKit/Sources/SparkKit/API/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,31 @@ public actor APIClient {
// MARK: - Public entrypoints

public func request<Response>(_ endpoint: Endpoint<Response>) async throws -> Response {
try await perform(endpoint, absoluteBase: false, allowRefresh: true).decoded
}

public func requestWithRawResponse<Response>(_ endpoint: Endpoint<Response>) async throws -> RawAPIResponse<Response> {
try await perform(endpoint, absoluteBase: false, allowRefresh: true)
}

/// Hit an endpoint whose path is rooted at `/api` (not `/api/v1/mobile`).
/// Used for the OAuth token endpoints.
public func requestSiteRoot<Response>(_ endpoint: Endpoint<Response>) async throws -> Response {
try await perform(endpoint, absoluteBase: true, allowRefresh: false)
try await perform(endpoint, absoluteBase: true, allowRefresh: false).decoded
}

public func accessTokenRefreshingIfNeeded(
leeway: TimeInterval = 30,
forceRefresh: Bool = false
) async throws -> String? {
guard let tokens = await tokenStore.tokens() else { return nil }
guard forceRefresh || tokens.expiresAt <= Date().addingTimeInterval(leeway) else {
return tokens.accessToken
}
guard await tokenStore.hasRefreshToken() else {
return tokens.accessToken
}
return try await refreshTokens().accessToken
}

// MARK: - Core
Expand All @@ -153,7 +171,7 @@ public actor APIClient {
allowRefresh: Bool,
attempt: Int = 1,
isRefreshRequest: Bool = false
) async throws -> Response {
) async throws -> RawAPIResponse<Response> {
let url = try buildURL(endpoint: endpoint, absoluteBase: absoluteBase)
var request = URLRequest(url: url)
request.httpMethod = endpoint.method.rawValue
Expand All @@ -162,7 +180,9 @@ public actor APIClient {
request.httpBody = body
request.setValue(endpoint.contentType ?? "application/json", forHTTPHeaderField: "Content-Type")
}
let accessToken = endpoint.requiresAuth ? await tokenStore.accessToken() : nil
let accessToken = endpoint.requiresAuth
? try await accessTokenRefreshingIfNeeded()
: nil
if let accessToken {
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
Expand Down Expand Up @@ -294,7 +314,7 @@ public actor APIClient {
metrics: metricsCollector.snapshot,
outcome: .success
)
return empty
return RawAPIResponse(decoded: empty, data: data)
}

do {
Expand All @@ -314,7 +334,7 @@ public actor APIClient {
outcome: .success,
decodeDurationMillis: Date().timeIntervalSince(decodeStartedAt) * 1_000
)
return decoded
return RawAPIResponse(decoded: decoded, data: data)
} catch {
let bodyString = String(data: data, encoding: .utf8) ?? "<binary>"
logger.error("Decoding failed for \(endpoint.path, privacy: .public): \(error.localizedDescription, privacy: .public) — body: \(bodyString, privacy: .public)")
Expand All @@ -341,7 +361,7 @@ public actor APIClient {
absoluteBase: Bool,
retryAttempt: Int,
tokenUsedForRequest: String?
) async throws -> Response {
) async throws -> RawAPIResponse<Response> {
if let tokenUsedForRequest,
let currentAccessToken = await tokenStore.accessToken(),
currentAccessToken != tokenUsedForRequest {
Expand All @@ -364,7 +384,7 @@ public actor APIClient {
absoluteBase: true,
allowRefresh: false,
isRefreshRequest: true
)
).decoded
let authTokens = AuthTokens(
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
Expand Down Expand Up @@ -434,7 +454,7 @@ public actor APIClient {
private func buildURL<Response>(endpoint: Endpoint<Response>, absoluteBase: Bool) throws -> URL {
let base: URL
if absoluteBase {
base = oauthSiteRootURL()
base = oauthAPIRootURL()
} else {
base = environment.baseURL
}
Expand All @@ -449,14 +469,20 @@ public actor APIClient {
return url
}

private func oauthSiteRootURL() -> URL {
/// The OAuth token/refresh endpoints (`/oauth/token`, `/oauth/refresh`) are
/// registered in Laravel's `routes/api.php`, so they are served from the
/// API host under the `/api` prefix — *not* as root-level siblings of the
/// `/oauth/authorize` web route. Derive the base from `environment.baseURL`
/// (definitionally the API host, e.g. `https://host/api/v1/mobile`) and
/// anchor it at `/api` so endpoint paths resolve to `/api/oauth/token`.
private func oauthAPIRootURL() -> URL {
guard var components = URLComponents(
url: environment.oauthAuthorizeURL,
url: environment.baseURL,
resolvingAgainstBaseURL: false
) else {
return environment.baseURL
}
components.path = "/"
components.path = "/api"
components.query = nil
components.fragment = nil
return components.url ?? environment.baseURL
Expand Down Expand Up @@ -519,3 +545,12 @@ private extension HTTPURLResponse {
public struct EmptyResponse: Codable, Sendable {
public init() {}
}

public struct RawAPIResponse<Response: Sendable>: Sendable {
public let decoded: Response
public let data: Data

public var utf8Body: String {
String(data: data, encoding: .utf8) ?? "<binary response: \(data.count) bytes>"
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
import Foundation

public enum HealthEndpoint {
public enum DashboardRange: String, Sendable, CaseIterable {
case sevenDays = "7d"
case thirtyDays = "30d"
case ninetyDays = "90d"

public var label: String {
switch self {
case .sevenDays: "7D"
case .thirtyDays: "30D"
case .ninetyDays: "90D"
}
}
}

/// GET /health/dashboard?date=YYYY-MM-DD&range=7d
public static func dashboard(date: String = "today", range: DashboardRange = .sevenDays) -> Endpoint<HealthDashboard> {
Endpoint(
method: .get,
path: "/health/dashboard",
query: [
URLQueryItem(name: "date", value: date),
URLQueryItem(name: "range", value: range.rawValue),
]
)
}

/// POST /health/samples
public static func submit(samples: [HealthSample]) -> Endpoint<HealthSubmitResponse> {
let body = try? JSONEncoder().encode(HealthSampleBatch(samples: samples))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ public enum SearchEndpoint {
case integrations
case semantic

public var queryValue: String {
switch self {
case .tags: "tag"
default: rawValue
}
}

/// Single-character prefix used in the web Spotlight (`>` etc.). The
/// search bar swallows the prefix and switches `Mode`.
public var symbol: String? {
Expand Down Expand Up @@ -41,7 +48,7 @@ public enum SearchEndpoint {
path: "/search",
query: [
URLQueryItem(name: "q", value: text),
URLQueryItem(name: "mode", value: mode.rawValue),
URLQueryItem(name: "mode", value: mode.queryValue),
]
)
}
Expand Down
4 changes: 4 additions & 0 deletions Packages/SparkKit/Sources/SparkKit/Deeplinks/DeepLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public enum DeepLink: Sendable, Equatable {
case metric(identifier: String)
case place(id: String)
case integration(service: String)
case tag(name: String)

/// Parse an incoming URL. Returns nil when the URL doesn't match any
/// route — caller can fall through to default handling (e.g. opening
Expand Down Expand Up @@ -60,6 +61,9 @@ public enum DeepLink: Sendable, Equatable {
// /integrations/{service}/details
guard parts.count >= 3, parts[2] == "details" else { return nil }
return .integration(service: parts[1])
case "tags", "tag":
guard parts.count >= 2 else { return nil }
return .tag(name: parts[1])
default:
return nil
}
Expand Down
4 changes: 4 additions & 0 deletions Packages/SparkKit/Sources/SparkKit/Models/CheckIn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public struct CheckInRequest: Encodable, Sendable {
public let physical: Int
public let mental: Int
public let date: String
public let occurredAt: Date?
public let latitude: Double?
public let longitude: Double?
public let address: String?
Expand All @@ -24,6 +25,7 @@ public struct CheckInRequest: Encodable, Sendable {
physical: Int,
mental: Int,
date: String,
occurredAt: Date? = nil,
latitude: Double? = nil,
longitude: Double? = nil,
address: String? = nil,
Expand All @@ -33,6 +35,7 @@ public struct CheckInRequest: Encodable, Sendable {
self.physical = physical
self.mental = mental
self.date = date
self.occurredAt = occurredAt
self.latitude = latitude
self.longitude = longitude
self.address = address
Expand All @@ -41,6 +44,7 @@ public struct CheckInRequest: Encodable, Sendable {

enum CodingKeys: String, CodingKey {
case period, physical, mental, date, latitude, longitude, address, notes
case occurredAt = "occurred_at"
}
}

Expand Down
Loading
Loading