feat(protocol): validate generated gateway contracts
This commit is contained in:
+205
-5
@@ -151,6 +151,12 @@ def go_validation(definition: dict[str, Any]) -> list[str]:
|
||||
reference = ref_name(prop)
|
||||
if reference:
|
||||
lines.append(f"\tif err := v.{field}.Validate(); err != nil {{ violations = append(violations, FieldViolation{{Field: \"{prop_name}\", Code: \"invalid_object\"}}) }}")
|
||||
if name in {"AllocationPolicy", "ManifestBounds"}:
|
||||
lines.append("\tif v.MinimumKbps > v.TargetKbps || v.TargetKbps > v.MaximumKbps { violations = append(violations, FieldViolation{Field: \"bounds\", Code: \"invalid_order\"}) }")
|
||||
if name == "GatewayRegistration":
|
||||
lines.append("\tif v.ProtocolMinVersion > v.ProtocolMaxVersion { violations = append(violations, FieldViolation{Field: \"protocol_version\", Code: \"invalid_order\"}) }")
|
||||
if name == "ChannelFrame":
|
||||
lines.append("\tif v.FragmentIndex >= v.FragmentCount { violations = append(violations, FieldViolation{Field: \"fragment_index\", Code: \"invalid_order\"}) }")
|
||||
return lines
|
||||
|
||||
|
||||
@@ -235,6 +241,20 @@ def generate_go(defs: dict[str, dict[str, Any]], schema_hash: str, version: str,
|
||||
out.append("\treturn json.Marshal(value)")
|
||||
out.append("}")
|
||||
out.append("")
|
||||
out.extend([
|
||||
"var ErrNoCapabilityOverlap = errors.New(\"no capability overlap\")",
|
||||
"",
|
||||
"func IntersectCapabilityProfiles(profiles ...CapabilityProfile) (CapabilityProfile, error) {",
|
||||
"\tif len(profiles) == 0 { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
||||
"\tselected := profiles[0]",
|
||||
"\tif err := selected.Validate(); err != nil { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
||||
"\tfor _, profile := range profiles[1:] {",
|
||||
"\t\tif err := profile.Validate(); err != nil || profile != selected { return CapabilityProfile{}, ErrNoCapabilityOverlap }",
|
||||
"\t}",
|
||||
"\treturn selected, nil",
|
||||
"}",
|
||||
"",
|
||||
])
|
||||
# Use io.EOF in generated code without making every generated decoder depend on
|
||||
# error-string comparison; replace the deliberately compact placeholder.
|
||||
text = "\n".join(out).replace('"errors"\n"fmt"', '"errors"\n"fmt"\n\"io"')
|
||||
@@ -272,6 +292,58 @@ def swift_type(prop: dict[str, Any]) -> str:
|
||||
return "String"
|
||||
|
||||
|
||||
def rust_validation(definition: dict[str, Any]) -> list[str]:
|
||||
lines: list[str] = []
|
||||
required = set(definition.get("required", []))
|
||||
for prop_name, prop in definition.get("properties", {}).items():
|
||||
field = rust_field(prop_name)
|
||||
value = f"self.{field}"
|
||||
if prop_name not in required:
|
||||
value = f"value"
|
||||
lines.append(f" if let Some(value) = &self.{field} {{")
|
||||
prefix, suffix = " ", " }"
|
||||
else:
|
||||
prefix, suffix = "", ""
|
||||
if prop.get("type") == "string":
|
||||
if prop_name in required and prop.get("minLength", 0) > 0:
|
||||
lines.append(f" {prefix}if {value}.is_empty() {{ return Err(ValidationError::new(\"{prop_name}\", \"required\")); }}")
|
||||
if "minLength" in prop:
|
||||
lines.append(f" {prefix}if !{value}.is_empty() && {value}.len() < {prop['minLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_length\")); }}")
|
||||
if "maxLength" in prop:
|
||||
lines.append(f" {prefix}if {value}.len() > {prop['maxLength']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_length\")); }}")
|
||||
if "const" in prop:
|
||||
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
||||
if "enum" in prop:
|
||||
allowed = " && ".join(f'{value} != \"{item}\"' for item in prop["enum"])
|
||||
lines.append(f" {prefix}if {allowed} {{ return Err(ValidationError::new(\"{prop_name}\", \"invalid_value\")); }}")
|
||||
if prop.get("type") == "integer":
|
||||
if "minimum" in prop:
|
||||
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"minimum\")); }}")
|
||||
if "maximum" in prop:
|
||||
lines.append(f" {prefix}if {value} > {prop['maximum']} {{ return Err(ValidationError::new(\"{prop_name}\", \"maximum\")); }}")
|
||||
if prop.get("type") == "array":
|
||||
if "minItems" in prop:
|
||||
lines.append(f" {prefix}if {value}.len() < {prop['minItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"min_items\")); }}")
|
||||
if "maxItems" in prop:
|
||||
lines.append(f" {prefix}if {value}.len() > {prop['maxItems']} {{ return Err(ValidationError::new(\"{prop_name}\", \"max_items\")); }}")
|
||||
item_ref = ref_name(prop.get("items", {}))
|
||||
if item_ref:
|
||||
lines.append(f" {prefix}for item in {value}.iter() {{ item.validate().map_err(|_| ValidationError::new(\"{prop_name}\", \"invalid_item\"))?; }}")
|
||||
reference = ref_name(prop)
|
||||
if reference:
|
||||
lines.append(f" {prefix}{value}.validate().map_err(|_| ValidationError::new(\"{prop_name}\", \"invalid_object\"))?;")
|
||||
if suffix:
|
||||
lines.append(suffix)
|
||||
name = definition["name"]
|
||||
if name in {"AllocationPolicy", "ManifestBounds"}:
|
||||
lines.append(" if self.minimumKbps > self.targetKbps || self.targetKbps > self.maximumKbps { return Err(ValidationError::new(\"bounds\", \"invalid_order\")); }")
|
||||
if name == "GatewayRegistration":
|
||||
lines.append(" if self.protocolMinVersion > self.protocolMaxVersion { return Err(ValidationError::new(\"protocol_version\", \"invalid_order\")); }")
|
||||
if name == "ChannelFrame":
|
||||
lines.append(" if self.fragmentIndex >= self.fragmentCount { return Err(ValidationError::new(\"fragment_index\", \"invalid_order\")); }")
|
||||
return lines
|
||||
|
||||
|
||||
def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str:
|
||||
out = [
|
||||
"// Code generated by tools/generate.py; DO NOT EDIT.",
|
||||
@@ -282,6 +354,10 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
||||
f'pub const N_MINUS_2_WIRE_VERSION: &str = "{compatibility["n_minus_2"]}";',
|
||||
"pub type JsonObject = std::collections::BTreeMap<String, String>;",
|
||||
"",
|
||||
"#[derive(Debug, Clone, PartialEq, Eq)]",
|
||||
"pub struct ValidationError { pub field: &'static str, pub code: &'static str }",
|
||||
"impl ValidationError { pub const fn new(field: &'static str, code: &'static str) -> Self { Self { field, code } } }",
|
||||
"",
|
||||
]
|
||||
for name in sorted(defs):
|
||||
definition = defs[name]
|
||||
@@ -292,11 +368,103 @@ def generate_rust(defs: dict[str, dict[str, Any]], schema_hash: str, compatibili
|
||||
typ = rust_type(prop)
|
||||
if prop_name not in required:
|
||||
typ = f"Option<{typ}>"
|
||||
out.append(f" pub {field}: {typ},")
|
||||
out.append(f" {field}: {typ},")
|
||||
out.extend(["}", ""])
|
||||
parameters: list[str] = []
|
||||
assignments: list[str] = []
|
||||
for prop_name, prop in definition.get("properties", {}).items():
|
||||
field = rust_field(prop_name)
|
||||
typ = rust_type(prop)
|
||||
if prop_name not in required:
|
||||
typ = f"Option<{typ}>"
|
||||
parameters.append(f"{field}: {typ}")
|
||||
assignments.append(field)
|
||||
out.append(f"impl {name} {{")
|
||||
out.append(f" pub fn new({', '.join(parameters)}) -> Result<Self, ValidationError> {{")
|
||||
out.append(f" let value = Self {{ {', '.join(assignments)} }};")
|
||||
out.append(" value.validate()?;")
|
||||
out.append(" Ok(value)")
|
||||
out.append(" }")
|
||||
out.append(" pub fn validate(&self) -> Result<(), ValidationError> {")
|
||||
out.extend(rust_validation(definition))
|
||||
out.append(" Ok(())")
|
||||
out.append(" }")
|
||||
for prop_name, prop in definition.get("properties", {}).items():
|
||||
field = rust_field(prop_name)
|
||||
typ = rust_type(prop)
|
||||
if prop_name not in required:
|
||||
typ = f"Option<{typ}>"
|
||||
out.append(f" pub fn {field}(&self) -> &{typ} {{ &self.{field} }}")
|
||||
out.extend(["}", ""])
|
||||
out.extend([
|
||||
"pub fn intersect_capability_profiles(profiles: &[CapabilityProfile]) -> Result<CapabilityProfile, ValidationError> {",
|
||||
" let selected = profiles.first().ok_or_else(|| ValidationError::new(\"capabilities\", \"no_overlap\"))?.clone();",
|
||||
" selected.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
|
||||
" for profile in &profiles[1..] {",
|
||||
" profile.validate().map_err(|_| ValidationError::new(\"capabilities\", \"no_overlap\"))?;",
|
||||
" if profile != &selected { return Err(ValidationError::new(\"capabilities\", \"no_overlap\")); }",
|
||||
" }",
|
||||
" Ok(selected)",
|
||||
"}",
|
||||
"",
|
||||
])
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def swift_validation(definition: dict[str, Any]) -> list[str]:
|
||||
lines: list[str] = []
|
||||
required = set(definition.get("required", []))
|
||||
for prop_name, prop in definition.get("properties", {}).items():
|
||||
field = swift_field(prop_name)
|
||||
value = f"self.{field}"
|
||||
if prop_name not in required:
|
||||
value = "value"
|
||||
lines.append(f" if let value = self.{field} {{")
|
||||
prefix, suffix = " ", " }"
|
||||
else:
|
||||
prefix, suffix = "", ""
|
||||
if prop.get("type") == "string":
|
||||
if prop_name in required and prop.get("minLength", 0) > 0:
|
||||
lines.append(f" {prefix}if {value}.isEmpty {{ throw ContractValidationError(field: \"{prop_name}\", code: \"required\") }}")
|
||||
if "minLength" in prop:
|
||||
lines.append(f" {prefix}if !{value}.isEmpty && {value}.utf8.count < {prop['minLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_length\") }}")
|
||||
if "maxLength" in prop:
|
||||
lines.append(f" {prefix}if {value}.utf8.count > {prop['maxLength']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_length\") }}")
|
||||
if "const" in prop:
|
||||
lines.append(f" {prefix}if {value} != \"{prop['const']}\" {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
||||
if "enum" in prop:
|
||||
allowed = ", ".join(f'\"{item}\"' for item in prop["enum"])
|
||||
lines.append(f" {prefix}if ![{allowed}].contains({value}) {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_value\") }}")
|
||||
if prop.get("format") == "date-time":
|
||||
lines.append(f" {prefix}if ISO8601DateFormatter().date(from: {value}) == nil {{ throw ContractValidationError(field: \"{prop_name}\", code: \"invalid_time\") }}")
|
||||
if prop.get("type") == "integer":
|
||||
if "minimum" in prop:
|
||||
lines.append(f" {prefix}if {value} < {prop['minimum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"minimum\") }}")
|
||||
if "maximum" in prop:
|
||||
lines.append(f" {prefix}if {value} > {prop['maximum']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"maximum\") }}")
|
||||
if prop.get("type") == "array":
|
||||
if "minItems" in prop:
|
||||
lines.append(f" {prefix}if {value}.count < {prop['minItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"min_items\") }}")
|
||||
if "maxItems" in prop:
|
||||
lines.append(f" {prefix}if {value}.count > {prop['maxItems']} {{ throw ContractValidationError(field: \"{prop_name}\", code: \"max_items\") }}")
|
||||
item_ref = ref_name(prop.get("items", {}))
|
||||
if item_ref:
|
||||
lines.append(f" {prefix}for item in {value} {{ try item.validate() }}")
|
||||
reference = ref_name(prop)
|
||||
if reference:
|
||||
lines.append(f" {prefix}try {value}.validate()")
|
||||
if suffix:
|
||||
lines.append(suffix)
|
||||
name = definition["name"]
|
||||
if name in {"AllocationPolicy", "ManifestBounds"}:
|
||||
lines.append(" if minimumKbps > targetKbps || targetKbps > maximumKbps { throw ContractValidationError(field: \"bounds\", code: \"invalid_order\") }")
|
||||
if name == "GatewayRegistration":
|
||||
lines.append(" if protocolMinVersion > protocolMaxVersion { throw ContractValidationError(field: \"protocol_version\", code: \"invalid_order\") }")
|
||||
if name == "ChannelFrame":
|
||||
lines.append(" if fragmentIndex >= fragmentCount { throw ContractValidationError(field: \"fragment_index\", code: \"invalid_order\") }")
|
||||
return lines
|
||||
|
||||
|
||||
def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibility: dict[str, Any]) -> str:
|
||||
out = [
|
||||
"// Code generated by tools/generate.py; DO NOT EDIT.",
|
||||
@@ -306,6 +474,8 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
||||
f'public let currentWireVersion = "{compatibility["current"]}"',
|
||||
f'public let nMinus1WireVersion = "{compatibility["n_minus_1"]}"',
|
||||
f'public let nMinus2WireVersion = "{compatibility["n_minus_2"]}"',
|
||||
"public struct ContractValidationError: Error, Equatable { public let field: String; public let code: String }",
|
||||
"private struct AnyCodingKey: CodingKey { let stringValue: String; let intValue: Int?; init?(stringValue: String) { self.stringValue = stringValue; self.intValue = nil }; init?(intValue: Int) { self.stringValue = String(intValue); self.intValue = intValue } }",
|
||||
"",
|
||||
]
|
||||
for name in sorted(defs):
|
||||
@@ -320,16 +490,46 @@ def generate_swift(defs: dict[str, dict[str, Any]], schema_hash: str, compatibil
|
||||
out.append(" enum CodingKeys: String, CodingKey {")
|
||||
for prop_name in definition.get("properties", {}):
|
||||
out.append(f" case {swift_field(prop_name)} = \"{prop_name}\"")
|
||||
out.extend([" }", "", " public init(from decoder: Decoder) throws {",])
|
||||
parameters: list[str] = []
|
||||
for prop_name, prop in definition.get("properties", {}).items():
|
||||
typ = swift_type(prop)
|
||||
if prop_name not in required:
|
||||
typ += "?"
|
||||
parameters.append(f"{swift_field(prop_name)}: {typ}")
|
||||
out.extend([" }", "", f" public init({', '.join(parameters)}) throws {{"])
|
||||
for prop_name in definition.get("properties", {}):
|
||||
field = swift_field(prop_name)
|
||||
out.append(f" self.{field} = {field}")
|
||||
out.extend([" try validate()", " }", "", " public init(from decoder: Decoder) throws {"])
|
||||
out.append(" let all = try decoder.container(keyedBy: AnyCodingKey.self)")
|
||||
out.append(" for key in all.allKeys where CodingKeys(stringValue: key.stringValue) == nil { throw ContractValidationError(field: key.stringValue, code: \"unknown_field\") }")
|
||||
out.append(" let c = try decoder.container(keyedBy: CodingKeys.self)")
|
||||
decoded: list[str] = []
|
||||
for prop_name, prop in definition.get("properties", {}).items():
|
||||
field = swift_field(prop_name)
|
||||
typ = swift_type(prop)
|
||||
if prop_name in required:
|
||||
out.append(f" {field} = try c.decode({typ}.self, forKey: .{field})")
|
||||
decoded.append(f"{field}: try c.decode({typ}.self, forKey: .{field})")
|
||||
else:
|
||||
out.append(f" {field} = try c.decodeIfPresent({typ}.self, forKey: .{field})")
|
||||
out.extend([" }", "}", ""])
|
||||
decoded.append(f"{field}: try c.decodeIfPresent({typ}.self, forKey: .{field})")
|
||||
out.append(f" try self.init({', '.join(decoded)})")
|
||||
out.extend([" }", "", " public func validate() throws {"])
|
||||
out.extend(swift_validation(definition))
|
||||
out.extend([" }", "", " public static func decodeJSON(_ data: Data) throws -> Self { try JSONDecoder().decode(Self.self, from: data) }", " public func encodeJSON() throws -> Data { try validate(); return try JSONEncoder().encode(self) }", "}", ""])
|
||||
out.extend([
|
||||
"public extension CapabilityProfile {",
|
||||
" static func intersection(_ profiles: [CapabilityProfile]) throws -> CapabilityProfile {",
|
||||
" guard let selected = profiles.first else { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
|
||||
" try selected.validate()",
|
||||
" for profile in profiles.dropFirst() {",
|
||||
" try profile.validate()",
|
||||
" if profile != selected { throw ContractValidationError(field: \"capabilities\", code: \"no_overlap\") }",
|
||||
" }",
|
||||
" return selected",
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
])
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user