SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
28 days agolast push
Swift 100%
11.7 KB · 253 lines swift
Raw Blame History
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