Swift 98.3%
Shell 1.7%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// ApiPlaygroundView.swift — le playground NATIF d'API·Ka : openapi.json lu en3// direct, liste des endpoints GET groupés par tag, formulaire de paramètres4// généré depuis le schéma, envoi avec latence mesurée, réponse JSON colorisée5// et PLIABLE (arbre), « Copier en curl » dans le presse-papier macOS.6import SwiftUI7import AppKit89// MARK: - Modèle d'endpoint (extrait d'openapi.json)1011struct ApiParam: Identifiable {12 let name: String13 let location: String // query | path14 let required: Bool15 let type: String16 let defaultValue: String?17 var id: String { "\(location):\(name)" }18}1920struct ApiEndpoint: Identifiable, Hashable {21 static func == (lhs: ApiEndpoint, rhs: ApiEndpoint) -> Bool { lhs.id == rhs.id }22 func hash(into h: inout Hasher) { h.combine(id) }23 let id: String24 let path: String25 let summary: String26 let detail: String?27 let tag: String28 let params: [ApiParam]29}3031// MARK: - Playground3233struct ApiPlaygroundView: View {34 let universe: Universe35 @EnvironmentObject private var pulse: EcosystemPulse36 @Environment(\.colorScheme) private var scheme3738 @State private var endpoints: [ApiEndpoint] = []39 @State private var loadFailed = false40 @State private var selected: ApiEndpoint?41 @State private var values: [String: String] = [:]42 @State private var sending = false43 @State private var response: JSONValue?44 @State private var responseRaw = ""45 @State private var statusCode: Int?46 @State private var latencyMs: Int?47 @State private var copied = false4849 private var grouped: [(tag: String, eps: [ApiEndpoint])] {50 Dictionary(grouping: endpoints, by: \.tag)51 .map { ($0.key, $0.value.sorted { $0.path < $1.path }) }52 .sorted { $0.0 < $1.0 }53 }5455 var body: some View {56 VStack(spacing: 0) {57 hero58 Divider().opacity(0.4)59 HStack(spacing: 0) {60 endpointList61 .frame(width: 330)62 Divider().opacity(0.4)63 requestPane64 .frame(maxWidth: .infinity)65 }66 }67 .task { await loadSpec() }68 }6970 private var hero: some View {71 HStack(alignment: .firstTextBaseline, spacing: 12) {72 KAWordmark(universe: universe, size: 24)73 Text("Playground live — openapi.json lu à la source")74 .font(.system(.subheadline, design: .rounded).weight(.bold))75 .foregroundStyle(KATheme.ink2(scheme))76 Spacer()77 Link(destination: URL(string: "https://www.api-ka.com/docs")!) {78 Label("Docs /docs", systemImage: "book")79 .font(.caption.weight(.semibold))80 }81 .foregroundStyle(universe.accent)82 Link(destination: universe.baseURL) {83 Label("Ouvrir le site", systemImage: "safari")84 .font(.caption.weight(.semibold))85 }86 .foregroundStyle(universe.accent)87 }88 .padding(.horizontal, 16).padding(.vertical, 12)89 }9091 // MARK: liste des GET9293 private var endpointList: some View {94 ScrollView {95 LazyVStack(alignment: .leading, spacing: 6) {96 if loadFailed {97 KAEmptyState(symbol: "wifi.exclamationmark", title: "Spécification indisponible",98 message: "Impossible de lire openapi.json — réessayez.")99 } else if endpoints.isEmpty {100 ForEach(0..<6, id: \.self) { _ in KASkeletonRow() }101 } else {102 ForEach(grouped, id: \.tag) { group in103 Text(group.tag.uppercased())104 .font(.system(.caption2, design: .monospaced).weight(.bold))105 .foregroundStyle(.secondary)106 .padding(.top, 8)107 ForEach(group.eps) { ep in108 Button {109 select(ep)110 } label: {111 VStack(alignment: .leading, spacing: 2) {112 HStack(spacing: 6) {113 Text("GET")114 .font(.system(size: 9, weight: .bold, design: .monospaced))115 .padding(.horizontal, 5).padding(.vertical, 2)116 .background(KATheme.green.opacity(0.15), in: RoundedRectangle(cornerRadius: 4))117 .foregroundStyle(KATheme.green)118 Text(ep.path)119 .font(.system(.caption, design: .monospaced).weight(.semibold))120 .lineLimit(1)121 }122 if !ep.summary.isEmpty {123 Text(ep.summary).font(.caption2).foregroundStyle(.secondary).lineLimit(1)124 }125 }126 .padding(8)127 .frame(maxWidth: .infinity, alignment: .leading)128 .background(selected?.id == ep.id ? universe.accent.opacity(0.14) : KATheme.surface(scheme),129 in: RoundedRectangle(cornerRadius: 8, style: .continuous))130 .overlay(RoundedRectangle(cornerRadius: 8, style: .continuous)131 .strokeBorder(selected?.id == ep.id ? universe.accent : KATheme.ink(scheme).opacity(0.25),132 lineWidth: selected?.id == ep.id ? 1.6 : 1))133 }134 .buttonStyle(.plain)135 }136 }137 }138 }139 .padding(12)140 }141 .background(KATheme.paper(scheme))142 }143144 // MARK: requête + réponse145146 @ViewBuilder147 private var requestPane: some View {148 if let ep = selected {149 ScrollView {150 VStack(alignment: .leading, spacing: 14) {151 VStack(alignment: .leading, spacing: 6) {152 HStack(spacing: 8) {153 Text("GET").font(.system(.caption, design: .monospaced).weight(.bold))154 .padding(.horizontal, 7).padding(.vertical, 3)155 .background(KATheme.green.opacity(0.15), in: RoundedRectangle(cornerRadius: 5))156 .foregroundStyle(KATheme.green)157 Text(ep.path).font(.system(.body, design: .monospaced).weight(.bold))158 .textSelection(.enabled)159 }160 if let d = ep.detail, !d.isEmpty {161 Text(d).font(.caption).foregroundStyle(.secondary)162 }163 }164165 if !ep.params.isEmpty {166 VStack(spacing: 0) {167 ForEach(ep.params) { p in168 HStack(spacing: 10) {169 VStack(alignment: .leading, spacing: 1) {170 HStack(spacing: 4) {171 Text(p.name).font(.system(.caption, design: .monospaced).weight(.bold))172 if p.required { Text("requis").font(.system(size: 9)).foregroundStyle(.red) }173 }174 Text("\(p.location) · \(p.type)")175 .font(.system(size: 9, design: .monospaced))176 .foregroundStyle(.tertiary)177 }178 .frame(width: 150, alignment: .leading)179 TextField(p.defaultValue ?? "", text: Binding(180 get: { values[p.id] ?? "" },181 set: { values[p.id] = $0 }))182 .textFieldStyle(.roundedBorder)183 .font(.system(.caption, design: .monospaced))184 }185 .padding(.vertical, 7).padding(.horizontal, 12)186 if p.id != ep.params.last?.id { Divider() }187 }188 }189 .kaCard()190 }191192 HStack(spacing: 10) {193 Button {194 Task { await send(ep) }195 } label: {196 Label(sending ? "Envoi…" : "Envoyer", systemImage: "paperplane.fill")197 .font(.caption.weight(.bold))198 .padding(.horizontal, 14).padding(.vertical, 8)199 .background(universe.accent, in: Capsule())200 .foregroundStyle(.white)201 }202 .buttonStyle(.plain)203 .keyboardShortcut(.defaultAction) // ⏎ envoie la requête204 .disabled(sending)205 Button {206 copyCurl(ep)207 } label: {208 Label(copied ? "Copié ✓" : "Copier en curl", systemImage: "terminal")209 .font(.caption.weight(.bold))210 }211 .help("Copie la commande curl équivalente dans le presse-papier")212 Spacer()213 if let code = statusCode {214 Text("HTTP \(code)")215 .font(.system(.caption, design: .monospaced).weight(.bold))216 .foregroundStyle((200..<300).contains(code) ? KATheme.green : .red)217 }218 if let ms = latencyMs {219 Text("\(ms) ms")220 .font(.system(.caption, design: .monospaced).weight(.bold))221 .padding(.horizontal, 8).padding(.vertical, 3)222 .background(KATheme.green.opacity(0.12), in: Capsule())223 .foregroundStyle(ms < 800 ? KATheme.green : .orange)224 }225 }226227 if sending {228 HStack(spacing: 8) { ProgressView().controlSize(.small); Text("Requête en cours…").font(.caption).foregroundStyle(.secondary) }229 } else if let r = response {230 VStack(alignment: .leading, spacing: 8) {231 HStack {232 Text("Réponse").font(.headline)233 Spacer()234 Button {235 NSPasteboard.general.clearContents()236 NSPasteboard.general.setString(responseRaw, forType: .string)237 } label: {238 Label("Copier le JSON", systemImage: "doc.on.doc").font(.caption)239 }240 }241 ScrollView(.horizontal) {242 JSONTreeView(key: nil, value: r, depth: 0)243 .padding(12)244 }245 .frame(maxWidth: .infinity, alignment: .leading)246 .kaCard(accent: universe.accent)247 }248 }249 }250 .padding(16)251 }252 .background(KATheme.paper(scheme))253 } else {254 KAEmptyState(symbol: "terminal",255 title: "Choisissez un endpoint",256 message: "La donnée de tout l'écosystème, interrogeable en direct — formulaire généré depuis le schéma OpenAPI.")257 .frame(maxWidth: .infinity, maxHeight: .infinity)258 .background(KATheme.paper(scheme))259 }260 }261262 // MARK: actions263264 private func select(_ ep: ApiEndpoint) {265 selected = ep266 values = [:]267 response = nil268 statusCode = nil269 latencyMs = nil270 for p in ep.params where p.defaultValue != nil { values[p.id] = "" }271 }272273 private func buildURL(_ ep: ApiEndpoint) -> URL? {274 var path = ep.path275 for p in ep.params where p.location == "path" {276 let v = values[p.id]?.trimmingCharacters(in: .whitespaces) ?? ""277 path = path.replacingOccurrences(of: "{\(p.name)}", with: v.isEmpty ? "0" : v)278 }279 var comps = URLComponents(string: "https://www.api-ka.com\(path)")280 let q = ep.params281 .filter { $0.location == "query" }282 .compactMap { p -> URLQueryItem? in283 let v = (values[p.id] ?? "").trimmingCharacters(in: .whitespaces)284 let final = v.isEmpty ? (p.defaultValue ?? "") : v285 guard !final.isEmpty else { return nil }286 return URLQueryItem(name: p.name, value: final)287 }288 if !q.isEmpty { comps?.queryItems = q }289 return comps?.url290 }291292 private func send(_ ep: ApiEndpoint) async {293 guard let url = buildURL(ep) else { return }294 sending = true295 defer { sending = false }296 var req = URLRequest(url: url)297 req.timeoutInterval = 20298 req.setValue("KA-macOS/1.0 (+https://www.groupe-ka.com)", forHTTPHeaderField: "User-Agent")299 let start = Date()300 do {301 let (data, resp) = try await URLSession.shared.data(for: req)302 latencyMs = Int(Date().timeIntervalSince(start) * 1000)303 statusCode = (resp as? HTTPURLResponse)?.statusCode304 responseRaw = prettyJSON(data) ?? String(data: data, encoding: .utf8) ?? ""305 response = try? JSONDecoder().decode(JSONValue.self, from: data)306 if response == nil {307 response = .string(responseRaw.isEmpty ? "(réponse vide)" : String(responseRaw.prefix(4000)))308 }309 } catch {310 latencyMs = Int(Date().timeIntervalSince(start) * 1000)311 statusCode = nil312 response = .string("Erreur réseau : \(error.localizedDescription)")313 responseRaw = ""314 }315 }316317 private func prettyJSON(_ data: Data) -> String? {318 guard let obj = try? JSONSerialization.jsonObject(with: data),319 let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys])320 else { return nil }321 return String(data: pretty, encoding: .utf8)322 }323324 private func copyCurl(_ ep: ApiEndpoint) {325 guard let url = buildURL(ep) else { return }326 let cmd = "curl -s '\(url.absoluteString)' -H 'Accept: application/json'"327 NSPasteboard.general.clearContents()328 NSPasteboard.general.setString(cmd, forType: .string)329 copied = true330 Task { try? await Task.sleep(for: .seconds(2)); copied = false }331 }332333 // MARK: openapi.json → endpoints GET334335 private func loadSpec() async {336 guard endpoints.isEmpty else { return }337 guard let url = URL(string: "https://www.api-ka.com/openapi.json"),338 let root = try? await APIClient.shared.json(url, ttl: 3600),339 let paths = root.object?["paths"]?.object else {340 loadFailed = true341 return342 }343 var eps: [ApiEndpoint] = []344 for (path, methods) in paths {345 guard let get = methods.object?["get"]?.object else { continue }346 let params: [ApiParam] = (get["parameters"]?.array ?? []).compactMap { p in347 guard let po = p.object, let name = po.str("name") else { return nil }348 let schema = po["schema"]?.object349 let required: Bool = { if case .bool(true) = po["required"] ?? .null { return true }; return false }()350 return ApiParam(name: name,351 location: po.str("in") ?? "query",352 required: required,353 type: schema?.str("type") ?? schema?["anyOf"]?.array?.first?.object?.str("type") ?? "string",354 defaultValue: schema?["default"]?.text)355 }356 eps.append(ApiEndpoint(id: "GET \(path)",357 path: path,358 summary: get.str("summary") ?? "",359 detail: get.str("description"),360 tag: get["tags"]?.array?.first?.text ?? "divers",361 params: params))362 }363 endpoints = eps.sorted { $0.path < $1.path }364 loadFailed = eps.isEmpty365 // démo vivante : /health sélectionné et interrogé d'emblée (inoffensif)366 if selected == nil, let health = endpoints.first(where: { $0.path == "/health" }) {367 select(health)368 await send(health)369 }370 }371}372373// MARK: - Arbre JSON colorisé & pliable374375struct JSONTreeView: View {376 let key: String?377 let value: JSONValue378 let depth: Int379 @State private var expanded: Bool380381 init(key: String?, value: JSONValue, depth: Int) {382 self.key = key383 self.value = value384 self.depth = depth385 _expanded = State(initialValue: depth < 2)386 }387388 var body: some View {389 switch value {390 case .object(let o):391 if o.isEmpty { leaf(text: "{}", color: .secondary) }392 else {393 DisclosureGroup(isExpanded: $expanded) {394 VStack(alignment: .leading, spacing: 2) {395 ForEach(o.keys.sorted(), id: \.self) { k in396 JSONTreeView(key: k, value: o[k]!, depth: depth + 1)397 }398 }399 .padding(.leading, 8)400 } label: {401 label(suffix: "{ \(o.count) }", color: .secondary)402 }403 .disclosureGroupStyle(KADisclosure())404 }405 case .array(let a):406 if a.isEmpty { leaf(text: "[]", color: .secondary) }407 else {408 DisclosureGroup(isExpanded: $expanded) {409 VStack(alignment: .leading, spacing: 2) {410 ForEach(Array(a.prefix(50).enumerated()), id: \.offset) { i, v in411 JSONTreeView(key: "[\(i)]", value: v, depth: depth + 1)412 }413 if a.count > 50 {414 Text("… \(a.count - 50) de plus")415 .font(.system(size: 11, design: .monospaced))416 .foregroundStyle(.tertiary)417 }418 }419 .padding(.leading, 8)420 } label: {421 label(suffix: "[ \(a.count) ]", color: .secondary)422 }423 .disclosureGroupStyle(KADisclosure())424 }425 case .string(let s):426 leaf(text: "\"\(s.count > 200 ? String(s.prefix(200)) + "…" : s)\"", color: Color(hex: "#1c7ed6"))427 case .number(let n):428 leaf(text: n == n.rounded() ? String(Int(n)) : String(n), color: Color(hex: "#1f9d55"))429 case .bool(let b):430 leaf(text: b ? "true" : "false", color: Color(hex: "#f08c00"))431 case .null:432 leaf(text: "null", color: .secondary)433 }434 }435436 private func label(suffix: String, color: Color) -> some View {437 HStack(spacing: 5) {438 if let key {439 Text(key).font(.system(size: 12, weight: .bold, design: .monospaced))440 Text(":").foregroundStyle(.tertiary)441 }442 Text(suffix).font(.system(size: 11, design: .monospaced)).foregroundStyle(color)443 }444 }445446 private func leaf(text: String, color: Color) -> some View {447 HStack(alignment: .top, spacing: 5) {448 if let key {449 Text(key).font(.system(size: 12, weight: .bold, design: .monospaced))450 Text(":").foregroundStyle(.tertiary)451 }452 Text(text)453 .font(.system(size: 12, design: .monospaced))454 .foregroundStyle(color)455 .textSelection(.enabled)456 }457 }458}459460/// Style de pliage compact (chevron discret, sans indentation système).461struct KADisclosure: DisclosureGroupStyle {462 func makeBody(configuration: Configuration) -> some View {463 VStack(alignment: .leading, spacing: 2) {464 Button {465 withAnimation(.snappy(duration: 0.15)) { configuration.isExpanded.toggle() }466 } label: {467 HStack(spacing: 4) {468 Image(systemName: "chevron.right")469 .font(.system(size: 8, weight: .bold))470 .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0))471 .foregroundStyle(.secondary)472 configuration.label473 }474 }475 .buttonStyle(.plain)476 if configuration.isExpanded {477 configuration.content478 }479 }480 }481}482