Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// APIPlaygroundView.swift — le PLAYGROUND API·Ka natif : la spec OpenAPI est3// chargée EN DIRECT (openapi.json), les endpoints GET sont listés, le4// formulaire de paramètres est généré, on envoie la vraie requête et on lit5// la réponse JSON colorisée avec statut + latence + « Copier en curl ».6import SwiftUI78struct APIEndpoint: Identifiable, Hashable {9 let id: String // chemin10 let path: String11 let summary: String?12 let params: [Param]13 struct Param: Identifiable, Hashable {14 var id: String { name }15 let name: String16 let required: Bool17 let inPath: Bool // {service} dans le chemin18 let description: String?19 }20}2122struct APIPlaygroundView: View {23 let universe: Universe24 @State private var endpoints: [APIEndpoint] = []25 @State private var selected: APIEndpoint?26 @State private var values: [String: String] = [:]27 @State private var response: String = ""28 @State private var status: Int?29 @State private var latencyMs: Int?30 @State private var sending = false31 @State private var loadFailed = false32 @Environment(\.colorScheme) private var scheme3334 private var builtURL: URL? {35 guard let e = selected else { return nil }36 var path = e.path37 for p in e.params where p.inPath {38 let v = values[p.name] ?? ""39 guard !v.isEmpty else { return nil }40 path = path.replacingOccurrences(of: "{\(p.name)}", with: v)41 }42 var comps = URLComponents(string: "https://www.api-ka.com\(path)")!43 let qs = e.params.filter { !$0.inPath }44 .compactMap { p -> URLQueryItem? in45 guard let v = values[p.name], !v.isEmpty else { return nil }46 return URLQueryItem(name: p.name, value: v)47 }48 comps.queryItems = qs.isEmpty ? nil : qs49 return comps.url50 }5152 var body: some View {53 ScrollView {54 VStack(alignment: .leading, spacing: 16) {55 VStack(alignment: .leading, spacing: 4) {56 Text("PLAYGROUND · SPEC OPENAPI EN DIRECT")57 .font(.system(size: 10, design: .monospaced).weight(.bold))58 .foregroundStyle(universe.accent)59 Text("Essayez l'API de l'écosystème")60 .font(.title3.weight(.bold))61 Text("Les données quotidiennes des services Ka, ouvertes et documentées.")62 .font(.caption).foregroundStyle(.secondary)63 }6465 if endpoints.isEmpty && !loadFailed {66 ForEach(0..<4, id: \.self) { _ in KASkeletonRow() }67 } else if loadFailed {68 KAEmptyState(symbol: "wifi.exclamationmark", title: "Spec inaccessible",69 message: "Impossible de charger openapi.json — réessayez.")70 }7172 // endpoints73 VStack(spacing: 8) {74 ForEach(endpoints) { e in75 Button {76 Haptics.tap()77 withAnimation(.snappy) {78 selected = e79 values = [:]80 response = ""; status = nil; latencyMs = nil81 }82 } label: {83 HStack(spacing: 10) {84 Text("GET")85 .font(.system(size: 10, design: .monospaced).weight(.bold))86 .padding(.horizontal, 7).padding(.vertical, 3)87 .background(universe.accent.opacity(selected?.id == e.id ? 1 : 0.15), in: RoundedRectangle(cornerRadius: 6))88 .foregroundStyle(selected?.id == e.id ? .white : universe.accent)89 VStack(alignment: .leading, spacing: 1) {90 Text(e.path).font(.system(.caption, design: .monospaced).weight(.bold))91 .lineLimit(1).minimumScaleFactor(0.7)92 if let s = e.summary {93 Text(s).font(.caption2).foregroundStyle(.secondary).lineLimit(1)94 }95 }96 Spacer()97 Image(systemName: selected?.id == e.id ? "chevron.down" : "chevron.right")98 .font(.caption2).foregroundStyle(.tertiary)99 }100 .padding(11)101 .kaCard(accent: selected?.id == e.id ? universe.accent : nil)102 }103 .buttonStyle(KAPressStyle())104105 if selected?.id == e.id {106 requestPanel(e)107 }108 }109 }110 }111 .padding(16)112 }113 .background(KATheme.paper(scheme))114 .task { await loadSpec() }115 }116117 // MARK: panneau de requête118119 @ViewBuilder120 private func requestPanel(_ e: APIEndpoint) -> some View {121 VStack(alignment: .leading, spacing: 10) {122 ForEach(e.params) { p in123 HStack(spacing: 8) {124 Text(p.name)125 .font(.system(.caption, design: .monospaced).weight(.bold))126 .frame(width: 86, alignment: .leading)127 TextField(p.inPath ? "requis (chemin)" : (p.required ? "requis" : "optionnel"),128 text: Binding(get: { values[p.name] ?? "" },129 set: { values[p.name] = $0 }))130 .font(.system(.caption, design: .monospaced))131 .textInputAutocapitalization(.never)132 .autocorrectionDisabled()133 .padding(8)134 .background(KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8))135 .overlay(RoundedRectangle(cornerRadius: 8)136 .strokeBorder(p.required && (values[p.name] ?? "").isEmpty ? universe.accent.opacity(0.6) : Color.primary.opacity(0.25), lineWidth: 1))137 }138 }139 if let url = builtURL {140 Text(url.absoluteString)141 .font(.system(size: 10, design: .monospaced))142 .foregroundStyle(.secondary)143 .lineLimit(2)144 .textSelection(.enabled)145 }146 HStack(spacing: 10) {147 Button {148 Task { await send() }149 } label: {150 HStack {151 if sending { ProgressView().tint(.white) }152 Text(sending ? "Envoi…" : "Envoyer la requête ➤")153 .font(.subheadline.weight(.bold))154 }155 .padding(.horizontal, 16).padding(.vertical, 11)156 .background(builtURL == nil ? Color.gray : universe.accent, in: Capsule())157 .foregroundStyle(.white)158 }159 .disabled(builtURL == nil || sending)160 if let url = builtURL {161 Button {162 UIPasteboard.general.string = "curl -s '\(url.absoluteString)'"163 Haptics.success()164 } label: {165 Label("curl", systemImage: "doc.on.doc")166 .font(.caption.weight(.bold))167 .padding(.horizontal, 12).padding(.vertical, 11)168 .background(KATheme.inkLight, in: Capsule())169 .foregroundStyle(KATheme.lime)170 }171 .accessibilityLabel("Copier la commande curl")172 }173 Spacer()174 if let s = status {175 HStack(spacing: 6) {176 Circle().fill(s == 200 ? .green : .red).frame(width: 8, height: 8)177 Text("\(s)").font(.system(.caption, design: .monospaced).weight(.bold))178 if let ms = latencyMs {179 Text("· \(ms) ms").font(.system(.caption2, design: .monospaced)).foregroundStyle(.secondary)180 }181 }182 }183 }184 if !response.isEmpty {185 ScrollView([.vertical, .horizontal]) {186 Text(response)187 .font(.system(size: 11, design: .monospaced))188 .textSelection(.enabled)189 .padding(12)190 .frame(maxWidth: .infinity, alignment: .leading)191 }192 .frame(maxHeight: 340)193 .background(KATheme.inkLight, in: RoundedRectangle(cornerRadius: 12, style: .continuous))194 .foregroundStyle(Color(hex: "#d9f2c9"))195 }196 }197 .padding(12)198 .background(universe.accent.opacity(0.06), in: RoundedRectangle(cornerRadius: 12, style: .continuous))199 }200201 // MARK: réseau202203 private func loadSpec() async {204 guard endpoints.isEmpty else { return }205 guard let url = URL(string: "https://www.api-ka.com/openapi.json"),206 let root = try? await APIClient.shared.json(url, ttl: 600),207 let paths = root.object?["paths"]?.object else { loadFailed = true; return }208 var eps: [APIEndpoint] = []209 for (path, methods) in paths.sorted(by: { $0.key < $1.key }) {210 guard let get = methods.object?["get"]?.object else { continue }211 let params: [APIEndpoint.Param] = (get["parameters"]?.array ?? []).compactMap { p in212 guard let po = p.object, let name = po.str("name") else { return nil }213 let inPath = po.str("in") == "path"214 let required: Bool = { if case .bool(true) = po["required"] ?? .null { return true }; return inPath }()215 return .init(name: name, required: required, inPath: inPath,216 description: po.str("description"))217 }218 eps.append(APIEndpoint(id: path, path: path,219 summary: get.str("summary") ?? get.str("description")?.prefix(70).description,220 params: params))221 }222 withAnimation(.snappy) { endpoints = eps }223 }224225 private func send() async {226 guard let url = builtURL else { return }227 sending = true228 Haptics.rigid()229 defer { sending = false }230 let start = Date()231 do {232 var req = URLRequest(url: url)233 req.setValue("application/json", forHTTPHeaderField: "Accept")234 let (data, resp) = try await URLSession.shared.data(for: req)235 latencyMs = Int(Date().timeIntervalSince(start) * 1000)236 status = (resp as? HTTPURLResponse)?.statusCode237 if let obj = try? JSONSerialization.jsonObject(with: data),238 let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]) {239 var text = String(decoding: pretty, as: UTF8.self)240 if text.count > 12000 { text = String(text.prefix(12000)) + "\n… (tronqué)" }241 response = text242 } else {243 response = String(decoding: data.prefix(8000), as: UTF8.self)244 }245 Haptics.success()246 } catch {247 status = nil248 latencyMs = nil249 response = "Erreur réseau : \(error.localizedDescription)"250 }251 }252}253