Files
hyungi_document_server/Sources/DSKit/JSONValue.swift
T
Hyungi 0becf7829e feat(s3): SwiftPM scaffold + DSKit data layer + 14-fixture acceptance
- Package.swift: AI (S2-owned) + DSKit (models/client/fixtures) + DSKitTests, tools 6.2, .swiftLanguageMode(.v6), .macOS(.v26)
- JSONValue (Sendable AnyCodable), DSDate (value-type ISO8601FormatStyle cascade, date-only UTC), explicit-CodingKeys decoder
- Models: Auth/Document(+Detail flat-compose, MD-first)/Catalog/Search+Ask/Memo/Digest; non-optional limited to id/file_type/created+updated_at/total
- DSClient protocol + FixtureDSClient (Bundle.module, zero backend) + DSError + DSConfig + DownloadURL (?token= query)
- Tests: 14-fixture contract acceptance (value asserts) + JSONValue number trap + Ask round-trip + AI router fallback/explicit-unavailable

swift build + swift test green (19 tests). Sources/AI untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 17:16:55 +09:00

72 lines
3.1 KiB
Swift

import Foundation
/// A Sendable, fully-Codable representation of arbitrary JSON, for the contract's open-shape
/// `[String: Any]?` fields (ai_suggestion, md_frontmatter, md_extraction_quality, memo_task_state,
/// source_metadata, freshness_debug, debug). Plain Codable cannot decode those; this can.
///
/// Numeric note (B-1 review): on Foundation's JSONDecoder, a JSON boolean fails `Int` decode
/// (no silent 1/0 coercion), so Bool ordering is harmless. The real disambiguation is Int-before-Double
/// so integral numbers stay `.int`. BUT whole-valued floats like `1.0` can land as `.int` depending on
/// toolchain so numeric meaning MUST be read through `doubleValue`/`intValue` (which cross-convert),
/// never by pattern-matching the raw case.
public enum JSONValue: Codable, Sendable, Hashable {
case string(String)
case int(Int)
case double(Double)
case bool(Bool)
case object([String: JSONValue])
case array([JSONValue])
case null
public init(from decoder: Decoder) throws {
let c = try decoder.singleValueContainer()
if c.decodeNil() { self = .null; return }
if let b = try? c.decode(Bool.self) { self = .bool(b); return }
if let i = try? c.decode(Int.self) { self = .int(i); return }
if let d = try? c.decode(Double.self) { self = .double(d); return }
if let s = try? c.decode(String.self) { self = .string(s); return }
if let o = try? c.decode([String: JSONValue].self) { self = .object(o); return }
if let a = try? c.decode([JSONValue].self) { self = .array(a); return }
throw DecodingError.dataCorruptedError(in: c, debugDescription: "Unsupported JSON value")
}
public func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
switch self {
case .string(let s): try c.encode(s)
case .int(let i): try c.encode(i)
case .double(let d): try c.encode(d)
case .bool(let b): try c.encode(b)
case .object(let o): try c.encode(o)
case .array(let a): try c.encode(a)
case .null: try c.encodeNil()
}
}
// Accessors cross-convert so numeric reads are robust regardless of int/double storage.
public var stringValue: String? { if case .string(let s) = self { return s }; return nil }
public var intValue: Int? {
switch self {
case .int(let i): return i
case .double(let d): return Int(d)
default: return nil
}
}
public var doubleValue: Double? {
switch self {
case .double(let d): return d
case .int(let i): return Double(i)
default: return nil
}
}
public var boolValue: Bool? { if case .bool(let b) = self { return b }; return nil }
public var objectValue: [String: JSONValue]? { if case .object(let o) = self { return o }; return nil }
public var arrayValue: [JSONValue]? { if case .array(let a) = self { return a }; return nil }
public subscript(key: String) -> JSONValue? { objectValue?[key] }
public subscript(index: Int) -> JSONValue? {
guard let a = arrayValue, index >= 0, index < a.count else { return nil }
return a[index]
}
}