// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // APIPlaygroundView.swift — le PLAYGROUND API·Ka natif : la spec OpenAPI est // chargée EN DIRECT (openapi.json), les endpoints GET sont listés, le // formulaire de paramètres est généré, on envoie la vraie requête et on lit // la réponse JSON colorisée avec statut + latence + « Copier en curl ». import SwiftUI struct APIEndpoint: Identifiable, Hashable { let id: String // chemin let path: String let summary: String? let params: [Param] struct Param: Identifiable, Hashable { var id: String { name } let name: String let required: Bool let inPath: Bool // {service} dans le chemin let description: String? } } struct APIPlaygroundView: View { let universe: Universe @State private var endpoints: [APIEndpoint] = [] @State private var selected: APIEndpoint? @State private var values: [String: String] = [:] @State private var response: String = "" @State private var status: Int? @State private var latencyMs: Int? @State private var sending = false @State private var loadFailed = false @Environment(\.colorScheme) private var scheme private var builtURL: URL? { guard let e = selected else { return nil } var path = e.path for p in e.params where p.inPath { let v = values[p.name] ?? "" guard !v.isEmpty else { return nil } path = path.replacingOccurrences(of: "{\(p.name)}", with: v) } var comps = URLComponents(string: "https://www.api-ka.com\(path)")! let qs = e.params.filter { !$0.inPath } .compactMap { p -> URLQueryItem? in guard let v = values[p.name], !v.isEmpty else { return nil } return URLQueryItem(name: p.name, value: v) } comps.queryItems = qs.isEmpty ? nil : qs return comps.url } var body: some View { ScrollView { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 4) { Text("PLAYGROUND · SPEC OPENAPI EN DIRECT") .font(.system(size: 10, design: .monospaced).weight(.bold)) .foregroundStyle(universe.accent) Text("Essayez l'API de l'écosystème") .font(.title3.weight(.bold)) Text("Les données quotidiennes des services Ka, ouvertes et documentées.") .font(.caption).foregroundStyle(.secondary) } if endpoints.isEmpty && !loadFailed { ForEach(0..<4, id: \.self) { _ in KASkeletonRow() } } else if loadFailed { KAEmptyState(symbol: "wifi.exclamationmark", title: "Spec inaccessible", message: "Impossible de charger openapi.json — réessayez.") } // endpoints VStack(spacing: 8) { ForEach(endpoints) { e in Button { Haptics.tap() withAnimation(.snappy) { selected = e values = [:] response = ""; status = nil; latencyMs = nil } } label: { HStack(spacing: 10) { Text("GET") .font(.system(size: 10, design: .monospaced).weight(.bold)) .padding(.horizontal, 7).padding(.vertical, 3) .background(universe.accent.opacity(selected?.id == e.id ? 1 : 0.15), in: RoundedRectangle(cornerRadius: 6)) .foregroundStyle(selected?.id == e.id ? .white : universe.accent) VStack(alignment: .leading, spacing: 1) { Text(e.path).font(.system(.caption, design: .monospaced).weight(.bold)) .lineLimit(1).minimumScaleFactor(0.7) if let s = e.summary { Text(s).font(.caption2).foregroundStyle(.secondary).lineLimit(1) } } Spacer() Image(systemName: selected?.id == e.id ? "chevron.down" : "chevron.right") .font(.caption2).foregroundStyle(.tertiary) } .padding(11) .kaCard(accent: selected?.id == e.id ? universe.accent : nil) } .buttonStyle(KAPressStyle()) if selected?.id == e.id { requestPanel(e) } } } } .padding(16) } .background(KATheme.paper(scheme)) .task { await loadSpec() } } // MARK: panneau de requête @ViewBuilder private func requestPanel(_ e: APIEndpoint) -> some View { VStack(alignment: .leading, spacing: 10) { ForEach(e.params) { p in HStack(spacing: 8) { Text(p.name) .font(.system(.caption, design: .monospaced).weight(.bold)) .frame(width: 86, alignment: .leading) TextField(p.inPath ? "requis (chemin)" : (p.required ? "requis" : "optionnel"), text: Binding(get: { values[p.name] ?? "" }, set: { values[p.name] = $0 })) .font(.system(.caption, design: .monospaced)) .textInputAutocapitalization(.never) .autocorrectionDisabled() .padding(8) .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8)) .overlay(RoundedRectangle(cornerRadius: 8) .strokeBorder(p.required && (values[p.name] ?? "").isEmpty ? universe.accent.opacity(0.6) : Color.primary.opacity(0.25), lineWidth: 1)) } } if let url = builtURL { Text(url.absoluteString) .font(.system(size: 10, design: .monospaced)) .foregroundStyle(.secondary) .lineLimit(2) .textSelection(.enabled) } HStack(spacing: 10) { Button { Task { await send() } } label: { HStack { if sending { ProgressView().tint(.white) } Text(sending ? "Envoi…" : "Envoyer la requête ➤") .font(.subheadline.weight(.bold)) } .padding(.horizontal, 16).padding(.vertical, 11) .background(builtURL == nil ? Color.gray : universe.accent, in: Capsule()) .foregroundStyle(.white) } .disabled(builtURL == nil || sending) if let url = builtURL { Button { UIPasteboard.general.string = "curl -s '\(url.absoluteString)'" Haptics.success() } label: { Label("curl", systemImage: "doc.on.doc") .font(.caption.weight(.bold)) .padding(.horizontal, 12).padding(.vertical, 11) .background(KATheme.inkLight, in: Capsule()) .foregroundStyle(KATheme.lime) } .accessibilityLabel("Copier la commande curl") } Spacer() if let s = status { HStack(spacing: 6) { Circle().fill(s == 200 ? .green : .red).frame(width: 8, height: 8) Text("\(s)").font(.system(.caption, design: .monospaced).weight(.bold)) if let ms = latencyMs { Text("· \(ms) ms").font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary) } } } } if !response.isEmpty { ScrollView([.vertical, .horizontal]) { Text(response) .font(.system(size: 11, design: .monospaced)) .textSelection(.enabled) .padding(12) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 340) .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .foregroundStyle(Color(hex: "#d9f2c9")) } } .padding(12) .background(universe.accent.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) } // MARK: réseau private func loadSpec() async { guard endpoints.isEmpty else { return } guard let url = URL(string: "https://www.api-ka.com/openapi.json"), let root = try? await APIClient.shared.json(url, ttl: 600), let paths = root.object?["paths"]?.object else { loadFailed = true; return } var eps: [APIEndpoint] = [] for (path, methods) in paths.sorted(by: { $0.key < $1.key }) { guard let get = methods.object?["get"]?.object else { continue } let params: [APIEndpoint.Param] = (get["parameters"]?.array ?? []).compactMap { p in guard let po = p.object, let name = po.str("name") else { return nil } let inPath = po.str("in") == "path" let required: Bool = { if case .bool(true) = po["required"] ?? .null { return true }; return inPath }() return .init(name: name, required: required, inPath: inPath, description: po.str("description")) } eps.append(APIEndpoint(id: path, path: path, summary: get.str("summary") ?? get.str("description")?.prefix(70).description, params: params)) } withAnimation(.snappy) { endpoints = eps } } private func send() async { guard let url = builtURL else { return } sending = true Haptics.rigid() defer { sending = false } let start = Date() do { var req = URLRequest(url: url) req.setValue("application/json", forHTTPHeaderField: "Accept") let (data, resp) = try await URLSession.shared.data(for: req) latencyMs = Int(Date().timeIntervalSince(start) * 1000) status = (resp as? HTTPURLResponse)?.statusCode if let obj = try? JSONSerialization.jsonObject(with: data), let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]) { var text = String(decoding: pretty, as: UTF8.self) if text.count > 12000 { text = String(text.prefix(12000)) + "\n… (tronqué)" } response = text } else { response = String(decoding: data.prefix(8000), as: UTF8.self) } Haptics.success() } catch { status = nil latencyMs = nil response = "Erreur réseau : \(error.localizedDescription)" } } }