Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,34 @@ struct AgentChannelCustomHTTPAction: Codable, Equatable, Sendable {
var query: [String: String]
var headers: [String: String]
var bodyTemplate: String?
var successStatusCodes: [Int]
var responseMapping: AgentChannelCustomHTTPResponseMapping
var idempotency: AgentChannelCustomHTTPIdempotency?
var timeoutSeconds: Double?
var maxResponseBytes: Int?

init(
method: String = "GET",
path: String,
query: [String: String] = [:],
headers: [String: String] = [:],
bodyTemplate: String? = nil
bodyTemplate: String? = nil,
successStatusCodes: [Int] = Array(200 ... 299),
responseMapping: AgentChannelCustomHTTPResponseMapping = AgentChannelCustomHTTPResponseMapping(),
idempotency: AgentChannelCustomHTTPIdempotency? = nil,
timeoutSeconds: Double? = nil,
maxResponseBytes: Int? = nil
) {
self.method = method.uppercased()
self.path = path
self.query = query
self.headers = headers
self.bodyTemplate = bodyTemplate
self.successStatusCodes = Self.normalizedStatusCodes(successStatusCodes)
self.responseMapping = responseMapping.normalized
self.idempotency = idempotency?.normalized
self.timeoutSeconds = timeoutSeconds.map(Self.clampTimeout)
self.maxResponseBytes = maxResponseBytes.map(Self.clampResponseBytes)
}

init(from decoder: Decoder) throws {
Expand All @@ -65,21 +80,144 @@ struct AgentChannelCustomHTTPAction: Codable, Equatable, Sendable {
query = try container.decodeIfPresent([String: String].self, forKey: .query) ?? [:]
headers = try container.decodeIfPresent([String: String].self, forKey: .headers) ?? [:]
bodyTemplate = try container.decodeIfPresent(String.self, forKey: .bodyTemplate)
successStatusCodes = Self.normalizedStatusCodes(
try container.decodeIfPresent([Int].self, forKey: .successStatusCodes) ?? Array(200 ... 299)
)
responseMapping =
try container.decodeIfPresent(AgentChannelCustomHTTPResponseMapping.self, forKey: .responseMapping)?
.normalized ?? AgentChannelCustomHTTPResponseMapping()
idempotency =
try container.decodeIfPresent(AgentChannelCustomHTTPIdempotency.self, forKey: .idempotency)?
.normalized
timeoutSeconds = try container.decodeIfPresent(Double.self, forKey: .timeoutSeconds).map(Self.clampTimeout)
maxResponseBytes = try container.decodeIfPresent(Int.self, forKey: .maxResponseBytes)
.map(Self.clampResponseBytes)
}

var normalized: AgentChannelCustomHTTPAction {
AgentChannelCustomHTTPAction(
method: method,
path: path,
query: query,
headers: headers,
bodyTemplate: bodyTemplate,
successStatusCodes: successStatusCodes,
responseMapping: responseMapping,
idempotency: idempotency,
timeoutSeconds: timeoutSeconds,
maxResponseBytes: maxResponseBytes
)
}

static func normalizedStatusCodes(_ codes: [Int]) -> [Int] {
var seen = Set<Int>()
let filtered = codes.filter { (100 ... 599).contains($0) && seen.insert($0).inserted }
return filtered.isEmpty ? Array(200 ... 299) : filtered
}

static func clampTimeout(_ value: Double) -> Double {
min(max(value, 1), 30)
}

static func clampResponseBytes(_ value: Int) -> Int {
min(max(value, 1_024), 1_048_576)
}
}

struct AgentChannelCustomHTTPConfiguration: Codable, Equatable, Sendable {
var baseURL: String
var allowedHosts: [String]
var allowedMethods: [String]
var allowInsecureHTTP: Bool
var timeoutSeconds: Double
var maxResponseBytes: Int
var actions: [String: AgentChannelCustomHTTPAction]

init(baseURL: String, actions: [String: AgentChannelCustomHTTPAction] = [:]) {
init(
baseURL: String,
allowedHosts: [String] = [],
allowedMethods: [String] = ["GET", "POST"],
allowInsecureHTTP: Bool = false,
timeoutSeconds: Double = 15,
maxResponseBytes: Int = 131_072,
actions: [String: AgentChannelCustomHTTPAction] = [:]
) {
self.baseURL = baseURL
self.actions = actions
self.allowedHosts = Self.normalizedHosts(allowedHosts)
self.allowedMethods = Self.normalizedMethods(allowedMethods)
self.allowInsecureHTTP = allowInsecureHTTP
self.timeoutSeconds = AgentChannelCustomHTTPAction.clampTimeout(timeoutSeconds)
self.maxResponseBytes = AgentChannelCustomHTTPAction.clampResponseBytes(maxResponseBytes)
self.actions = Self.normalizedActions(actions)
}

init(baseURL: String, actions: [AgentChannelAction: AgentChannelCustomHTTPAction]) {
self.baseURL = baseURL
self.actions = Dictionary(uniqueKeysWithValues: actions.map { ($0.rawValue, $1) })
self.init(
baseURL: baseURL,
actions: Dictionary(uniqueKeysWithValues: actions.map { ($0.rawValue, $1) })
)
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
baseURL = try container.decode(String.self, forKey: .baseURL)
allowedHosts = Self.normalizedHosts(try container.decodeIfPresent([String].self, forKey: .allowedHosts) ?? [])
allowedMethods = Self.normalizedMethods(
try container.decodeIfPresent([String].self, forKey: .allowedMethods) ?? ["GET", "POST"]
)
allowInsecureHTTP = try container.decodeIfPresent(Bool.self, forKey: .allowInsecureHTTP) ?? false
timeoutSeconds = AgentChannelCustomHTTPAction.clampTimeout(
try container.decodeIfPresent(Double.self, forKey: .timeoutSeconds) ?? 15
)
maxResponseBytes = AgentChannelCustomHTTPAction.clampResponseBytes(
try container.decodeIfPresent(Int.self, forKey: .maxResponseBytes) ?? 131_072
)
actions = Self.normalizedActions(
try container.decodeIfPresent([String: AgentChannelCustomHTTPAction].self, forKey: .actions) ?? [:]
)
}

var normalized: AgentChannelCustomHTTPConfiguration {
AgentChannelCustomHTTPConfiguration(
baseURL: baseURL,
allowedHosts: allowedHosts,
allowedMethods: allowedMethods,
allowInsecureHTTP: allowInsecureHTTP,
timeoutSeconds: timeoutSeconds,
maxResponseBytes: maxResponseBytes,
actions: actions
)
}

static func normalizedHosts(_ hosts: [String]) -> [String] {
var seen = Set<String>()
return hosts.map {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
.lowercased()
}
.filter { !$0.isEmpty && seen.insert($0).inserted }
}

static func normalizedMethods(_ methods: [String]) -> [String] {
var seen = Set<String>()
let normalized = methods.map {
$0.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
}
.filter { !$0.isEmpty && seen.insert($0).inserted }
return normalized.isEmpty ? ["GET", "POST"] : normalized
}

static func normalizedActions(
_ actions: [String: AgentChannelCustomHTTPAction]
) -> [String: AgentChannelCustomHTTPAction] {
var normalized: [String: AgentChannelCustomHTTPAction] = [:]
for (key, action) in actions {
let trimmedKey = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedKey.isEmpty else { continue }
normalized[trimmedKey] = action.normalized
}
return normalized
}
}

Expand Down Expand Up @@ -140,7 +278,7 @@ struct AgentChannelConnection: Codable, Equatable, Identifiable, Sendable {
writeEnabled: writeEnabled,
defaultReadLimit: defaultReadLimit,
secrets: secrets,
customHTTP: customHTTP
customHTTP: customHTTP?.normalized
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
//
// AgentChannelCustomJSONModels.swift
// osaurus
//
// Safety and mapping options for configuration-only custom JSON channels.
//

import Foundation

struct AgentChannelCustomHTTPResponseMapping: Codable, Equatable, Sendable {
static let maxPathLength = 160
static let maxPathSegments = 12
static let maxArrayIndex = 1_000

var itemsPath: String?
var idPath: String?
var namePath: String?
var roomIdPath: String?
var threadIdPath: String?
var contentPath: String?
var authorIdPath: String?
var authorNamePath: String?
var timestampPath: String?
var cursorPath: String?

init(
itemsPath: String? = nil,
idPath: String? = nil,
namePath: String? = nil,
roomIdPath: String? = nil,
threadIdPath: String? = nil,
contentPath: String? = nil,
authorIdPath: String? = nil,
authorNamePath: String? = nil,
timestampPath: String? = nil,
cursorPath: String? = nil
) {
self.itemsPath = Self.trimmed(itemsPath)
self.idPath = Self.trimmed(idPath)
self.namePath = Self.trimmed(namePath)
self.roomIdPath = Self.trimmed(roomIdPath)
self.threadIdPath = Self.trimmed(threadIdPath)
self.contentPath = Self.trimmed(contentPath)
self.authorIdPath = Self.trimmed(authorIdPath)
self.authorNamePath = Self.trimmed(authorNamePath)
self.timestampPath = Self.trimmed(timestampPath)
self.cursorPath = Self.trimmed(cursorPath)
}

var normalized: AgentChannelCustomHTTPResponseMapping {
AgentChannelCustomHTTPResponseMapping(
itemsPath: itemsPath,
idPath: idPath,
namePath: namePath,
roomIdPath: roomIdPath,
threadIdPath: threadIdPath,
contentPath: contentPath,
authorIdPath: authorIdPath,
authorNamePath: authorNamePath,
timestampPath: timestampPath,
cursorPath: cursorPath
)
}

private static func trimmed(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}

var allConfiguredPaths: [String] {
[
itemsPath,
idPath,
namePath,
roomIdPath,
threadIdPath,
contentPath,
authorIdPath,
authorNamePath,
timestampPath,
cursorPath,
].compactMap { $0 }
}

static func validatePath(_ path: String) throws {
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard trimmed.utf8.count <= maxPathLength else {
throw AgentChannelCustomJSONRunnerError.invalidResponse(
"Response mapping path is too long.",
partialWriteStatus: nil
)
}
guard trimmed.rangeOfCharacter(from: .controlCharacters) == nil,
!trimmed.contains("{{"),
!trimmed.contains("}}"),
!trimmed.contains("["),
!trimmed.contains("]"),
!trimmed.contains("*")
else {
throw AgentChannelCustomJSONRunnerError.invalidResponse(
"Response mapping path `\(trimmed)` contains unsupported characters.",
partialWriteStatus: nil
)
}

let withoutRoot: String
if trimmed == "$" {
return
} else if trimmed.hasPrefix("$.") {
withoutRoot = String(trimmed.dropFirst(2))
} else {
withoutRoot = trimmed
}

let segments = withoutRoot.split(separator: ".", omittingEmptySubsequences: false).map(String.init)
guard !segments.isEmpty,
segments.count <= maxPathSegments,
segments.allSatisfy({ !$0.isEmpty })
else {
throw AgentChannelCustomJSONRunnerError.invalidResponse(
"Response mapping path `\(trimmed)` exceeds supported depth or has empty segments.",
partialWriteStatus: nil
)
}

for segment in segments {
if segment.allSatisfy(\.isNumber) {
guard let index = Int(segment), index <= maxArrayIndex else {
throw AgentChannelCustomJSONRunnerError.invalidResponse(
"Response mapping path `\(trimmed)` has an array index outside the supported range.",
partialWriteStatus: nil
)
}
continue
}
guard segment.range(of: #"^[A-Za-z0-9_-]+$"#, options: .regularExpression) != nil else {
throw AgentChannelCustomJSONRunnerError.invalidResponse(
"Response mapping path `\(trimmed)` contains unsupported segment `\(segment)`.",
partialWriteStatus: nil
)
}
}
}
}
struct AgentChannelCustomHTTPIdempotency: Codable, Equatable, Sendable {
var header: String?
var keyTemplate: String?
var responseIdPath: String?

init(
header: String? = "Idempotency-Key",
keyTemplate: String? = nil,
responseIdPath: String? = nil
) {
self.header = Self.trimmed(header)
self.keyTemplate = Self.trimmed(keyTemplate)
self.responseIdPath = Self.trimmed(responseIdPath)
}

var normalized: AgentChannelCustomHTTPIdempotency {
AgentChannelCustomHTTPIdempotency(
header: header,
keyTemplate: keyTemplate,
responseIdPath: responseIdPath
)
}

var configuredResponsePaths: [String] {
[responseIdPath].compactMap { $0 }
}

private static func trimmed(_ value: String?) -> String? {
guard let value else { return nil }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
}
Loading
Loading