More UI Changes? Not sure why they didn't go previously
This commit is contained in:
183
LabWiseKit/Sources/LabWiseKit/APIClient.swift
Normal file
183
LabWiseKit/Sources/LabWiseKit/APIClient.swift
Normal file
@@ -0,0 +1,183 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - API Errors
|
||||
|
||||
public enum APIError: Error, Sendable {
|
||||
case unauthorized
|
||||
case httpError(statusCode: Int, data: Data)
|
||||
case decodingError(Error)
|
||||
case encodingError(Error)
|
||||
case networkError(Error)
|
||||
case invalidURL
|
||||
}
|
||||
|
||||
// MARK: - APIClient
|
||||
|
||||
public final class APIClient: Sendable {
|
||||
public static let shared = APIClient()
|
||||
|
||||
private let baseURL = URL(string: "https://labwise.wahwa.com")!
|
||||
private let session: URLSession
|
||||
|
||||
/// Called when a 401 is received — UI layer should redirect to login.
|
||||
public nonisolated(unsafe) var onUnauthorized: (@Sendable () -> Void)?
|
||||
|
||||
private init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieStorage = HTTPCookieStorage.shared
|
||||
config.httpShouldSetCookies = true
|
||||
config.httpCookieAcceptPolicy = .always
|
||||
session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
// MARK: - Request helpers
|
||||
|
||||
func request(
|
||||
method: String,
|
||||
path: String,
|
||||
body: (any Encodable)? = nil,
|
||||
contentType: String = "application/json"
|
||||
) async throws -> Data {
|
||||
guard let url = URL(string: path, relativeTo: baseURL) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||
|
||||
if let body {
|
||||
do {
|
||||
req.httpBody = try JSONEncoder.api.encode(body)
|
||||
} catch {
|
||||
throw APIError.encodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.networkError(URLError(.badServerResponse))
|
||||
}
|
||||
|
||||
if http.statusCode == 401 {
|
||||
// Clear cookies and signal the app
|
||||
clearSessionCookies()
|
||||
onUnauthorized?()
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw APIError.httpError(statusCode: http.statusCode, data: data)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func get<T: Decodable>(_ path: String) async throws -> T {
|
||||
let data = try await request(method: "GET", path: path)
|
||||
return try decode(T.self, from: data, decoder: .api)
|
||||
}
|
||||
|
||||
func post<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||
let data = try await request(method: "POST", path: path, body: body)
|
||||
return try decode(Response.self, from: data, decoder: .api)
|
||||
}
|
||||
|
||||
func patch<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||
let data = try await request(method: "PATCH", path: path, body: body)
|
||||
return try decode(Response.self, from: data, decoder: .api)
|
||||
}
|
||||
|
||||
func delete(_ path: String) async throws {
|
||||
_ = try await request(method: "DELETE", path: path)
|
||||
}
|
||||
|
||||
// MARK: - Snake_case variants (for profile endpoint)
|
||||
|
||||
func getSnakeCase<T: Decodable>(_ path: String) async throws -> T {
|
||||
let data = try await request(method: "GET", path: path)
|
||||
return try decode(T.self, from: data, decoder: .snakeCase)
|
||||
}
|
||||
|
||||
func postSnakeCase<Body: Encodable, Response: Decodable>(_ path: String, body: Body) async throws -> Response {
|
||||
let encoded: Data
|
||||
do { encoded = try JSONEncoder.snakeCase.encode(body) }
|
||||
catch { throw APIError.encodingError(error) }
|
||||
let data = try await requestRaw(method: "POST", path: path, body: encoded)
|
||||
return try decode(Response.self, from: data, decoder: .snakeCase)
|
||||
}
|
||||
|
||||
// MARK: - Raw POST for auth (no decoded response needed)
|
||||
|
||||
public func postRaw(_ path: String, body: some Encodable) async throws -> Data {
|
||||
try await request(method: "POST", path: path, body: body)
|
||||
}
|
||||
|
||||
/// Low-level: sends pre-encoded body bytes.
|
||||
func requestRaw(method: String, path: String, body: Data) async throws -> Data {
|
||||
guard let url = URL(string: path, relativeTo: baseURL) else { throw APIError.invalidURL }
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = method
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = body
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.networkError(URLError(.badServerResponse))
|
||||
}
|
||||
if http.statusCode == 401 { clearSessionCookies(); onUnauthorized?(); throw APIError.unauthorized }
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
throw APIError.httpError(statusCode: http.statusCode, data: data)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func decode<T: Decodable>(_ type: T.Type, from data: Data, decoder: JSONDecoder) throws -> T {
|
||||
do {
|
||||
return try decoder.decode(type, from: data)
|
||||
} catch {
|
||||
throw APIError.decodingError(error)
|
||||
}
|
||||
}
|
||||
|
||||
func clearSessionCookies() {
|
||||
let storage = HTTPCookieStorage.shared
|
||||
if let cookies = storage.cookies(for: baseURL) {
|
||||
for cookie in cookies {
|
||||
storage.deleteCookie(cookie)
|
||||
}
|
||||
}
|
||||
// Also clear the better-auth session cookie by name
|
||||
if let allCookies = storage.cookies {
|
||||
for cookie in allCookies where cookie.name == "better-auth.session_token" {
|
||||
storage.deleteCookie(cookie)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Coder helpers
|
||||
|
||||
extension JSONEncoder {
|
||||
/// Chemicals and auth endpoints use camelCase keys as-is.
|
||||
static let api: JSONEncoder = JSONEncoder()
|
||||
|
||||
/// Profile endpoint uses snake_case keys.
|
||||
static let snakeCase: JSONEncoder = {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.keyEncodingStrategy = .convertToSnakeCase
|
||||
return encoder
|
||||
}()
|
||||
}
|
||||
|
||||
extension JSONDecoder {
|
||||
/// Chemicals and auth endpoints return camelCase keys as-is.
|
||||
static let api: JSONDecoder = JSONDecoder()
|
||||
|
||||
/// Profile endpoint returns snake_case keys.
|
||||
static let snakeCase: JSONDecoder = {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||
return decoder
|
||||
}()
|
||||
}
|
||||
233
LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift
Normal file
233
LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift
Normal file
@@ -0,0 +1,233 @@
|
||||
import Foundation
|
||||
import AuthenticationServices
|
||||
|
||||
public struct SignInBody: Encodable, Sendable {
|
||||
public let email: String
|
||||
public let password: String
|
||||
|
||||
public init(email: String, password: String) {
|
||||
self.email = email
|
||||
self.password = password
|
||||
}
|
||||
}
|
||||
|
||||
/// Better Auth wraps the sign-in response as { data: { user, session } }
|
||||
public struct SignInResponse: Decodable, Sendable {
|
||||
public let data: SignInData?
|
||||
public let error: SignInError?
|
||||
|
||||
public struct SignInData: Decodable, Sendable {
|
||||
public let user: AuthUser
|
||||
public let session: AuthSession
|
||||
}
|
||||
|
||||
public struct SignInError: Decodable, Sendable {
|
||||
public let message: String?
|
||||
}
|
||||
}
|
||||
|
||||
public struct AuthSession: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let token: String
|
||||
public let userId: String
|
||||
public let expiresAt: String
|
||||
}
|
||||
|
||||
public struct AuthUser: Decodable, Sendable {
|
||||
public let id: String
|
||||
public let email: String
|
||||
public let name: String?
|
||||
public let emailVerified: Bool?
|
||||
public let image: String?
|
||||
}
|
||||
|
||||
/// Thin wrapper around the /api/auth/session endpoint response.
|
||||
private struct SessionResponse: Decodable, Sendable {
|
||||
let user: AuthUser?
|
||||
let session: AuthSession?
|
||||
}
|
||||
|
||||
public final class AuthClient: Sendable {
|
||||
private let api: APIClient
|
||||
private let baseURL = "https://labwise.wahwa.com"
|
||||
|
||||
public init(api: APIClient = .shared) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
// MARK: - Email / Password
|
||||
|
||||
/// Sign in with email and password. Returns the authenticated user on success.
|
||||
public func signIn(email: String, password: String) async throws -> AuthUser {
|
||||
let body = SignInBody(email: email, password: password)
|
||||
let encoded = try JSONEncoder().encode(body)
|
||||
var req = URLRequest(url: URL(string: "\(baseURL)/api/auth/sign-in/email")!)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = encoded
|
||||
|
||||
let session = sharedURLSession()
|
||||
let (data, response) = try await session.data(for: req)
|
||||
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw APIError.networkError(URLError(.badServerResponse))
|
||||
}
|
||||
|
||||
// Better Auth may return 200 with an error body, or a non-200 status
|
||||
let signInResponse = try JSONDecoder.api.decode(SignInResponse.self, from: data)
|
||||
if let errMsg = signInResponse.error?.message {
|
||||
throw APIError.httpError(statusCode: http.statusCode, data: Data(errMsg.utf8))
|
||||
}
|
||||
guard http.statusCode == 200 else {
|
||||
throw APIError.httpError(statusCode: http.statusCode, data: data)
|
||||
}
|
||||
guard let user = signInResponse.data?.user else {
|
||||
throw APIError.decodingError(DecodingError.dataCorrupted(
|
||||
.init(codingPath: [], debugDescription: "Missing user in sign-in response")
|
||||
))
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// MARK: - Google OAuth
|
||||
|
||||
/// Initiates Google OAuth via ASWebAuthenticationSession.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Opens /api/auth/sign-in/google with callbackURL pointing at /api/ios-callback (HTTPS).
|
||||
/// 2. ASWebAuthenticationSession follows all redirects through Google and back to the server.
|
||||
/// 3. Better Auth sets the session cookie on labwise.wahwa.com, then redirects to /api/ios-callback.
|
||||
/// 4. /api/ios-callback reads the cookie, extracts the raw token, and redirects to labwise://auth?token=…
|
||||
/// 5. ASWebAuthenticationSession intercepts the labwise:// scheme and delivers the URL to us.
|
||||
/// 6. We parse the token, inject it as a cookie into HTTPCookieStorage.shared, then fetch the user.
|
||||
///
|
||||
/// This avoids the cookie-jar isolation problem: ASWebAuthenticationSession uses the Safari jar
|
||||
/// while URLSession uses HTTPCookieStorage.shared. By extracting the token from the URL we can
|
||||
/// bridge between the two.
|
||||
@MainActor
|
||||
public func signInWithGoogle(presentingWindow: ASPresentationAnchor) async throws -> AuthUser {
|
||||
let callbackScheme = "labwise"
|
||||
// callbackURL is a real HTTPS endpoint on our server — not the custom scheme.
|
||||
// Better Auth will redirect there after a successful Google sign-in.
|
||||
let iosCallbackURL = "\(baseURL)/api/ios-callback"
|
||||
|
||||
// /api/ios-google is a GET endpoint on our server that internally calls
|
||||
// Better Auth's POST /sign-in/social, forwards the state cookie it receives,
|
||||
// then redirects to Google. Because the whole flow happens within
|
||||
// ASWebAuthenticationSession's Safari jar, the state cookie is present when
|
||||
// Google redirects back — no state_mismatch.
|
||||
guard
|
||||
let encodedCallback = iosCallbackURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let authURL = URL(string: "\(baseURL)/api/ios-google?callbackURL=\(encodedCallback)")
|
||||
else { throw APIError.invalidURL }
|
||||
|
||||
let anchor = WindowAnchorProvider(window: presentingWindow)
|
||||
|
||||
// Keep a strong reference to the session outside the continuation closure
|
||||
// so it isn't deallocated before the callback fires.
|
||||
var webSession: ASWebAuthenticationSession?
|
||||
let callbackURL: URL = try await withCheckedThrowingContinuation { continuation in
|
||||
let session = ASWebAuthenticationSession(
|
||||
url: authURL,
|
||||
callbackURLScheme: callbackScheme
|
||||
) { url, error in
|
||||
if let error {
|
||||
continuation.resume(throwing: APIError.networkError(error))
|
||||
} else if let url {
|
||||
continuation.resume(returning: url)
|
||||
} else {
|
||||
continuation.resume(throwing: APIError.unauthorized)
|
||||
}
|
||||
}
|
||||
session.presentationContextProvider = anchor
|
||||
// Non-ephemeral so the in-browser Google account chooser can remember the user,
|
||||
// but the token is delivered via URL so the cookie jar difference doesn't matter.
|
||||
session.prefersEphemeralWebBrowserSession = false
|
||||
webSession = session
|
||||
session.start()
|
||||
}
|
||||
_ = webSession // keep alive until continuation resolves
|
||||
|
||||
// Parse labwise://auth?token=<value>&error=<value>
|
||||
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false) else {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
if let errorParam = components.queryItems?.first(where: { $0.name == "error" })?.value {
|
||||
throw APIError.httpError(statusCode: 401, data: Data(errorParam.utf8))
|
||||
}
|
||||
guard let rawToken = components.queryItems?.first(where: { $0.name == "token" })?.value,
|
||||
!rawToken.isEmpty else {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
|
||||
// URLComponents percent-decodes once; if the server double-encoded the token
|
||||
// (encodeURIComponent on an already-encoded value) decode any remaining %XX sequences.
|
||||
let token = rawToken.removingPercentEncoding ?? rawToken
|
||||
injectSessionCookie(token: token)
|
||||
|
||||
return try await fetchCurrentUser()
|
||||
}
|
||||
|
||||
// MARK: - Cookie injection
|
||||
|
||||
private func injectSessionCookie(token: String) {
|
||||
guard let serverURL = URL(string: baseURL) else { return }
|
||||
let properties: [HTTPCookiePropertyKey: Any] = [
|
||||
.name: "__Secure-better-auth.session_token",
|
||||
.value: token,
|
||||
.domain: serverURL.host ?? "labwise.wahwa.com",
|
||||
.path: "/",
|
||||
.secure: "TRUE",
|
||||
// Session cookie — no explicit expiry; persists until the app clears it.
|
||||
]
|
||||
if let cookie = HTTPCookie(properties: properties) {
|
||||
HTTPCookieStorage.shared.setCookie(cookie)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Session
|
||||
|
||||
/// Fetch the currently authenticated user from the session endpoint.
|
||||
public func fetchCurrentUser() async throws -> AuthUser {
|
||||
var req = URLRequest(url: URL(string: "\(baseURL)/api/auth/get-session")!)
|
||||
req.httpMethod = "GET"
|
||||
let session = sharedURLSession()
|
||||
let (data, response) = try await session.data(for: req)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
let sessionResponse = try JSONDecoder.api.decode(SessionResponse.self, from: data)
|
||||
guard let user = sessionResponse.user else {
|
||||
throw APIError.unauthorized
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// MARK: - Sign Out
|
||||
|
||||
/// Sign out. Clears the session cookie.
|
||||
public func signOut() async throws {
|
||||
_ = try? await api.postRaw("/api/auth/sign-out", body: EmptyBody())
|
||||
api.clearSessionCookies()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func sharedURLSession() -> URLSession {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.httpCookieStorage = HTTPCookieStorage.shared
|
||||
config.httpShouldSetCookies = true
|
||||
config.httpCookieAcceptPolicy = .always
|
||||
return URLSession(configuration: config)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ASWebAuthenticationPresentationContextProviding
|
||||
|
||||
private final class WindowAnchorProvider: NSObject, ASWebAuthenticationPresentationContextProviding, @unchecked Sendable {
|
||||
let window: ASPresentationAnchor
|
||||
init(window: ASPresentationAnchor) { self.window = window }
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { window }
|
||||
}
|
||||
|
||||
private struct EmptyBody: Encodable {}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
|
||||
public final class ChemicalsClient: Sendable {
|
||||
private let api: APIClient
|
||||
|
||||
public init(api: APIClient = .shared) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
public func list() async throws -> [Chemical] {
|
||||
try await api.get("/api/chemicals")
|
||||
}
|
||||
|
||||
public func create(_ body: ChemicalCreateBody) async throws -> Chemical {
|
||||
try await api.post("/api/chemicals", body: body)
|
||||
}
|
||||
|
||||
public func update(id: String, body: ChemicalCreateBody) async throws -> Chemical {
|
||||
try await api.patch("/api/chemicals/\(id)", body: body)
|
||||
}
|
||||
|
||||
public func delete(id: String) async throws {
|
||||
try await api.delete("/api/chemicals/\(id)")
|
||||
}
|
||||
}
|
||||
17
LabWiseKit/Sources/LabWiseKit/Endpoints/ProfileClient.swift
Normal file
17
LabWiseKit/Sources/LabWiseKit/Endpoints/ProfileClient.swift
Normal file
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
public final class ProfileClient: Sendable {
|
||||
private let api: APIClient
|
||||
|
||||
public init(api: APIClient = .shared) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
public func get() async throws -> UserProfile {
|
||||
try await api.get("/api/profile")
|
||||
}
|
||||
|
||||
public func upsert(_ body: UserProfileUpsertBody) async throws -> UserProfile {
|
||||
try await api.post("/api/profile", body: body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
public final class ProtocolsClient: Sendable {
|
||||
private let api: APIClient
|
||||
|
||||
public init(api: APIClient = .shared) {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
public func list() async throws -> [LabProtocol] {
|
||||
try await api.get("/api/protocols")
|
||||
}
|
||||
|
||||
public func delete(id: String) async throws {
|
||||
try await api.delete("/api/protocols/\(id)")
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
// The Swift Programming Language
|
||||
// https://docs.swift.org/swift-book
|
||||
// LabWiseKit — public re-exports
|
||||
// All public types are defined in their respective files.
|
||||
// This file intentionally left minimal.
|
||||
|
||||
@@ -2,7 +2,6 @@ import Foundation
|
||||
|
||||
public struct Chemical: Codable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
// Required
|
||||
public var piFirstName: String
|
||||
public var physicalState: String
|
||||
public var chemicalName: String
|
||||
@@ -14,7 +13,6 @@ public struct Chemical: Codable, Identifiable, Sendable {
|
||||
public var amountPerContainer: String
|
||||
public var unitOfMeasure: String
|
||||
public var casNumber: String
|
||||
// Optional
|
||||
public var chemicalFormula: String?
|
||||
public var molecularWeight: String?
|
||||
public var vendor: String?
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Foundation
|
||||
|
||||
/// A lab protocol document. Named `LabProtocol` to avoid collision with the Swift keyword `Protocol`.
|
||||
/// The server returns snake_case keys for this model.
|
||||
public struct LabProtocol: Codable, Identifiable, Sendable {
|
||||
public let id: String
|
||||
public var userId: String?
|
||||
public var title: String
|
||||
public var content: String
|
||||
public var fileUrl: String?
|
||||
@@ -11,11 +13,19 @@ public struct LabProtocol: Codable, Identifiable, Sendable {
|
||||
public var updatedAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, content, fileUrl, analysisResults, createdAt, updatedAt
|
||||
case id
|
||||
case userId = "user_id"
|
||||
case title
|
||||
case content
|
||||
case fileUrl = "file_url"
|
||||
case analysisResults = "analysis_results"
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
}
|
||||
|
||||
public init(
|
||||
id: String = "",
|
||||
userId: String? = nil,
|
||||
title: String = "",
|
||||
content: String = "",
|
||||
fileUrl: String? = nil,
|
||||
@@ -24,6 +34,7 @@ public struct LabProtocol: Codable, Identifiable, Sendable {
|
||||
updatedAt: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.userId = userId
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.fileUrl = fileUrl
|
||||
@@ -35,10 +46,10 @@ public struct LabProtocol: Codable, Identifiable, Sendable {
|
||||
|
||||
// MARK: - AnyCodable helper
|
||||
|
||||
public struct AnyCodable: Codable, Sendable {
|
||||
public let value: any Sendable
|
||||
public struct AnyCodable: Codable, @unchecked Sendable {
|
||||
public let value: Any
|
||||
|
||||
public init(_ value: any Sendable) {
|
||||
public init(_ value: Any) {
|
||||
self.value = value
|
||||
}
|
||||
|
||||
|
||||
54
LabWiseKit/Sources/LabWiseKit/Models/UserProfile.swift
Normal file
54
LabWiseKit/Sources/LabWiseKit/Models/UserProfile.swift
Normal file
@@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
/// Profile data returned by the server uses snake_case keys.
|
||||
public struct UserProfile: Codable, Sendable {
|
||||
public var userId: String
|
||||
public var piFirstName: String
|
||||
public var bldgCode: String
|
||||
public var lab: String
|
||||
public var contact: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case userId = "user_id"
|
||||
case piFirstName = "pi_first_name"
|
||||
case bldgCode = "bldg_code"
|
||||
case lab
|
||||
case contact
|
||||
}
|
||||
|
||||
public init(
|
||||
userId: String = "",
|
||||
piFirstName: String = "",
|
||||
bldgCode: String = "",
|
||||
lab: String = "",
|
||||
contact: String? = nil
|
||||
) {
|
||||
self.userId = userId
|
||||
self.piFirstName = piFirstName
|
||||
self.bldgCode = bldgCode
|
||||
self.lab = lab
|
||||
self.contact = contact
|
||||
}
|
||||
}
|
||||
|
||||
/// Request body for POST /api/profile — server expects snake_case keys.
|
||||
public struct UserProfileUpsertBody: Codable, Sendable {
|
||||
public var piFirstName: String
|
||||
public var bldgCode: String
|
||||
public var lab: String
|
||||
public var contact: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case piFirstName = "pi_first_name"
|
||||
case bldgCode = "bldg_code"
|
||||
case lab
|
||||
case contact
|
||||
}
|
||||
|
||||
public init(piFirstName: String, bldgCode: String, lab: String, contact: String? = nil) {
|
||||
self.piFirstName = piFirstName
|
||||
self.bldgCode = bldgCode
|
||||
self.lab = lab
|
||||
self.contact = contact
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user