// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // ApiPlaygroundView.swift — le playground NATIF d'API·Ka : openapi.json lu en // direct, liste des endpoints GET groupés par tag, formulaire de paramètres // généré depuis le schéma, envoi avec latence mesurée, réponse JSON colorisée // et PLIABLE (arbre), « Copier en curl » dans le presse-papier macOS. import SwiftUI import AppKit // MARK: - Modèle d'endpoint (extrait d'openapi.json) struct ApiParam: Identifiable { let name: String let location: String // query | path let required: Bool let type: String let defaultValue: String? var id: String { "\(location):\(name)" } } struct ApiEndpoint: Identifiable, Hashable { static func == (lhs: ApiEndpoint, rhs: ApiEndpoint) -> Bool { lhs.id == rhs.id } func hash(into h: inout Hasher) { h.combine(id) } let id: String let path: String let summary: String let detail: String? let tag: String let params: [ApiParam] } // MARK: - Playground struct ApiPlaygroundView: View { let universe: Universe @EnvironmentObject private var pulse: EcosystemPulse @Environment(\.colorScheme) private var scheme @State private var endpoints: [ApiEndpoint] = [] @State private var loadFailed = false @State private var selected: ApiEndpoint? @State private var values: [String: String] = [:] @State private var sending = false @State private var response: JSONValue? @State private var responseRaw = "" @State private var statusCode: Int? @State private var latencyMs: Int? @State private var copied = false private var grouped: [(tag: String, eps: [ApiEndpoint])] { Dictionary(grouping: endpoints, by: \.tag) .map { ($0.key, $0.value.sorted { $0.path < $1.path }) } .sorted { $0.0 < $1.0 } } var body: some View { VStack(spacing: 0) { hero Divider().opacity(0.4) HStack(spacing: 0) { endpointList .frame(width: 330) Divider().opacity(0.4) requestPane .frame(maxWidth: .infinity) } } .task { await loadSpec() } } private var hero: some View { HStack(alignment: .firstTextBaseline, spacing: 12) { KAWordmark(universe: universe, size: 24) Text("Playground live — openapi.json lu à la source") .font(.system(.subheadline, design: .rounded).weight(.bold)) .foregroundStyle(KATheme.ink2(scheme)) Spacer() Link(destination: URL(string: "https://www.api-ka.com/docs")!) { Label("Docs /docs", systemImage: "book") .font(.caption.weight(.semibold)) } .foregroundStyle(universe.accent) Link(destination: universe.baseURL) { Label("Ouvrir le site", systemImage: "safari") .font(.caption.weight(.semibold)) } .foregroundStyle(universe.accent) } .padding(.horizontal, 16).padding(.vertical, 12) } // MARK: liste des GET private var endpointList: some View { ScrollView { LazyVStack(alignment: .leading, spacing: 6) { if loadFailed { KAEmptyState(symbol: "wifi.exclamationmark", title: "Spécification indisponible", message: "Impossible de lire openapi.json — réessayez.") } else if endpoints.isEmpty { ForEach(0..<6, id: \.self) { _ in KASkeletonRow() } } else { ForEach(grouped, id: \.tag) { group in Text(group.tag.uppercased()) .font(.system(.caption2, design: .monospaced).weight(.bold)) .foregroundStyle(.secondary) .padding(.top, 8) ForEach(group.eps) { ep in Button { select(ep) } label: { VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text("GET") .font(.system(size: 9, weight: .bold, design: .monospaced)) .padding(.horizontal, 5).padding(.vertical, 2) .background(KATheme.green.opacity(0.15), in: RoundedRectangle(cornerRadius: 4)) .foregroundStyle(KATheme.green) Text(ep.path) .font(.system(.caption, design: .monospaced).weight(.semibold)) .lineLimit(1) } if !ep.summary.isEmpty { Text(ep.summary).font(.caption2).foregroundStyle(.secondary).lineLimit(1) } } .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .background(selected?.id == ep.id ? universe.accent.opacity(0.14) : KATheme.surface(scheme), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(selected?.id == ep.id ? universe.accent : KATheme.ink(scheme).opacity(0.25), lineWidth: selected?.id == ep.id ? 1.6 : 1)) } .buttonStyle(.plain) } } } } .padding(12) } .background(KATheme.paper(scheme)) } // MARK: requête + réponse @ViewBuilder private var requestPane: some View { if let ep = selected { ScrollView { VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Text("GET").font(.system(.caption, design: .monospaced).weight(.bold)) .padding(.horizontal, 7).padding(.vertical, 3) .background(KATheme.green.opacity(0.15), in: RoundedRectangle(cornerRadius: 5)) .foregroundStyle(KATheme.green) Text(ep.path).font(.system(.body, design: .monospaced).weight(.bold)) .textSelection(.enabled) } if let d = ep.detail, !d.isEmpty { Text(d).font(.caption).foregroundStyle(.secondary) } } if !ep.params.isEmpty { VStack(spacing: 0) { ForEach(ep.params) { p in HStack(spacing: 10) { VStack(alignment: .leading, spacing: 1) { HStack(spacing: 4) { Text(p.name).font(.system(.caption, design: .monospaced).weight(.bold)) if p.required { Text("requis").font(.system(size: 9)).foregroundStyle(.red) } } Text("\(p.location) · \(p.type)") .font(.system(size: 9, design: .monospaced)) .foregroundStyle(.tertiary) } .frame(width: 150, alignment: .leading) TextField(p.defaultValue ?? "", text: Binding( get: { values[p.id] ?? "" }, set: { values[p.id] = $0 })) .textFieldStyle(.roundedBorder) .font(.system(.caption, design: .monospaced)) } .padding(.vertical, 7).padding(.horizontal, 12) if p.id != ep.params.last?.id { Divider() } } } .kaCard() } HStack(spacing: 10) { Button { Task { await send(ep) } } label: { Label(sending ? "Envoi…" : "Envoyer", systemImage: "paperplane.fill") .font(.caption.weight(.bold)) .padding(.horizontal, 14).padding(.vertical, 8) .background(universe.accent, in: Capsule()) .foregroundStyle(.white) } .buttonStyle(.plain) .keyboardShortcut(.defaultAction) // ⏎ envoie la requête .disabled(sending) Button { copyCurl(ep) } label: { Label(copied ? "Copié ✓" : "Copier en curl", systemImage: "terminal") .font(.caption.weight(.bold)) } .help("Copie la commande curl équivalente dans le presse-papier") Spacer() if let code = statusCode { Text("HTTP \(code)") .font(.system(.caption, design: .monospaced).weight(.bold)) .foregroundStyle((200..<300).contains(code) ? KATheme.green : .red) } if let ms = latencyMs { Text("\(ms) ms") .font(.system(.caption, design: .monospaced).weight(.bold)) .padding(.horizontal, 8).padding(.vertical, 3) .background(KATheme.green.opacity(0.12), in: Capsule()) .foregroundStyle(ms < 800 ? KATheme.green : .orange) } } if sending { HStack(spacing: 8) { ProgressView().controlSize(.small); Text("Requête en cours…").font(.caption).foregroundStyle(.secondary) } } else if let r = response { VStack(alignment: .leading, spacing: 8) { HStack { Text("Réponse").font(.headline) Spacer() Button { NSPasteboard.general.clearContents() NSPasteboard.general.setString(responseRaw, forType: .string) } label: { Label("Copier le JSON", systemImage: "doc.on.doc").font(.caption) } } ScrollView(.horizontal) { JSONTreeView(key: nil, value: r, depth: 0) .padding(12) } .frame(maxWidth: .infinity, alignment: .leading) .kaCard(accent: universe.accent) } } } .padding(16) } .background(KATheme.paper(scheme)) } else { KAEmptyState(symbol: "terminal", title: "Choisissez un endpoint", message: "La donnée de tout l'écosystème, interrogeable en direct — formulaire généré depuis le schéma OpenAPI.") .frame(maxWidth: .infinity, maxHeight: .infinity) .background(KATheme.paper(scheme)) } } // MARK: actions private func select(_ ep: ApiEndpoint) { selected = ep values = [:] response = nil statusCode = nil latencyMs = nil for p in ep.params where p.defaultValue != nil { values[p.id] = "" } } private func buildURL(_ ep: ApiEndpoint) -> URL? { var path = ep.path for p in ep.params where p.location == "path" { let v = values[p.id]?.trimmingCharacters(in: .whitespaces) ?? "" path = path.replacingOccurrences(of: "{\(p.name)}", with: v.isEmpty ? "0" : v) } var comps = URLComponents(string: "https://www.api-ka.com\(path)") let q = ep.params .filter { $0.location == "query" } .compactMap { p -> URLQueryItem? in let v = (values[p.id] ?? "").trimmingCharacters(in: .whitespaces) let final = v.isEmpty ? (p.defaultValue ?? "") : v guard !final.isEmpty else { return nil } return URLQueryItem(name: p.name, value: final) } if !q.isEmpty { comps?.queryItems = q } return comps?.url } private func send(_ ep: ApiEndpoint) async { guard let url = buildURL(ep) else { return } sending = true defer { sending = false } var req = URLRequest(url: url) req.timeoutInterval = 20 req.setValue("KA-macOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent") let start = Date() do { let (data, resp) = try await URLSession.shared.data(for: req) latencyMs = Int(Date().timeIntervalSince(start) * 1000) statusCode = (resp as? HTTPURLResponse)?.statusCode responseRaw = prettyJSON(data) ?? String(data: data, encoding: .utf8) ?? "" response = try? JSONDecoder().decode(JSONValue.self, from: data) if response == nil { response = .string(responseRaw.isEmpty ? "(réponse vide)" : String(responseRaw.prefix(4000))) } } catch { latencyMs = Int(Date().timeIntervalSince(start) * 1000) statusCode = nil response = .string("Erreur réseau : \(error.localizedDescription)") responseRaw = "" } } private func prettyJSON(_ data: Data) -> String? { guard let obj = try? JSONSerialization.jsonObject(with: data), let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]) else { return nil } return String(data: pretty, encoding: .utf8) } private func copyCurl(_ ep: ApiEndpoint) { guard let url = buildURL(ep) else { return } let cmd = "curl -s '\(url.absoluteString)' -H 'Accept: application/json'" NSPasteboard.general.clearContents() NSPasteboard.general.setString(cmd, forType: .string) copied = true Task { try? await Task.sleep(for: .seconds(2)); copied = false } } // MARK: openapi.json → endpoints GET 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: 3600), let paths = root.object?["paths"]?.object else { loadFailed = true return } var eps: [ApiEndpoint] = [] for (path, methods) in paths { guard let get = methods.object?["get"]?.object else { continue } let params: [ApiParam] = (get["parameters"]?.array ?? []).compactMap { p in guard let po = p.object, let name = po.str("name") else { return nil } let schema = po["schema"]?.object let required: Bool = { if case .bool(true) = po["required"] ?? .null { return true }; return false }() return ApiParam(name: name, location: po.str("in") ?? "query", required: required, type: schema?.str("type") ?? schema?["anyOf"]?.array?.first?.object?.str("type") ?? "string", defaultValue: schema?["default"]?.text) } eps.append(ApiEndpoint(id: "GET \(path)", path: path, summary: get.str("summary") ?? "", detail: get.str("description"), tag: get["tags"]?.array?.first?.text ?? "divers", params: params)) } endpoints = eps.sorted { $0.path < $1.path } loadFailed = eps.isEmpty // démo vivante : /health sélectionné et interrogé d'emblée (inoffensif) if selected == nil, let health = endpoints.first(where: { $0.path == "/health" }) { select(health) await send(health) } } } // MARK: - Arbre JSON colorisé & pliable struct JSONTreeView: View { let key: String? let value: JSONValue let depth: Int @State private var expanded: Bool init(key: String?, value: JSONValue, depth: Int) { self.key = key self.value = value self.depth = depth _expanded = State(initialValue: depth < 2) } var body: some View { switch value { case .object(let o): if o.isEmpty { leaf(text: "{}", color: .secondary) } else { DisclosureGroup(isExpanded: $expanded) { VStack(alignment: .leading, spacing: 2) { ForEach(o.keys.sorted(), id: \.self) { k in JSONTreeView(key: k, value: o[k]!, depth: depth + 1) } } .padding(.leading, 8) } label: { label(suffix: "{ \(o.count) }", color: .secondary) } .disclosureGroupStyle(KADisclosure()) } case .array(let a): if a.isEmpty { leaf(text: "[]", color: .secondary) } else { DisclosureGroup(isExpanded: $expanded) { VStack(alignment: .leading, spacing: 2) { ForEach(Array(a.prefix(50).enumerated()), id: \.offset) { i, v in JSONTreeView(key: "[\(i)]", value: v, depth: depth + 1) } if a.count > 50 { Text("… \(a.count - 50) de plus") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(.tertiary) } } .padding(.leading, 8) } label: { label(suffix: "[ \(a.count) ]", color: .secondary) } .disclosureGroupStyle(KADisclosure()) } case .string(let s): leaf(text: "\"\(s.count > 200 ? String(s.prefix(200)) + "…" : s)\"", color: Color(hex: "#1c7ed6")) case .number(let n): leaf(text: n == n.rounded() ? String(Int(n)) : String(n), color: Color(hex: "#1f9d55")) case .bool(let b): leaf(text: b ? "true" : "false", color: Color(hex: "#f08c00")) case .null: leaf(text: "null", color: .secondary) } } private func label(suffix: String, color: Color) -> some View { HStack(spacing: 5) { if let key { Text(key).font(.system(size: 12, weight: .bold, design: .monospaced)) Text(":").foregroundStyle(.tertiary) } Text(suffix).font(.system(size: 11, design: .monospaced)).foregroundStyle(color) } } private func leaf(text: String, color: Color) -> some View { HStack(alignment: .top, spacing: 5) { if let key { Text(key).font(.system(size: 12, weight: .bold, design: .monospaced)) Text(":").foregroundStyle(.tertiary) } Text(text) .font(.system(size: 12, design: .monospaced)) .foregroundStyle(color) .textSelection(.enabled) } } } /// Style de pliage compact (chevron discret, sans indentation système). struct KADisclosure: DisclosureGroupStyle { func makeBody(configuration: Configuration) -> some View { VStack(alignment: .leading, spacing: 2) { Button { withAnimation(.snappy(duration: 0.15)) { configuration.isExpanded.toggle() } } label: { HStack(spacing: 4) { Image(systemName: "chevron.right") .font(.system(size: 8, weight: .bold)) .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0)) .foregroundStyle(.secondary) configuration.label } } .buttonStyle(.plain) if configuration.isExpanded { configuration.content } } } }