SPB Git forge

spb/ka-macos

Public
3commits 1branches 0releases
6.1 MBsize
maindefault branch
1 mo agolast push
Swift 98.3% Shell 1.7%
21.3 KB · 482 lines swift
Raw Blame History
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