1use serde_json::{Map, Value};
4
5use crate::compose::is_container_schema;
6use crate::error::{ResolveError, SchemaError, ValidateError};
7use crate::resolver::resolve;
8use crate::types::ResolveOptions;
9
10pub fn validate(
21 schema: &Value,
22 payload: &Value,
23 options: &ResolveOptions,
24) -> Result<(), ValidateError> {
25 let resolved = resolve(schema, options)?;
26
27 let target = select_operation_schema(&resolved, options)?;
31
32 validate_against_schema(&target, payload)
33}
34
35pub fn select_operation_schema(
54 schema: &Value,
55 options: &ResolveOptions,
56) -> Result<Value, ResolveError> {
57 if let Some(def) = &options.def_name {
58 return select_def(schema, def, SelectMode::Explicit);
59 }
60 if !is_container_schema(schema) {
61 return Ok(schema.clone());
62 }
63 let key = format!("{}_{}", options.operation, options.direction.dir_str());
64 select_def(schema, &key, SelectMode::Derived)
65}
66
67enum SelectMode {
71 Explicit,
72 Derived,
73}
74
75fn select_def(schema: &Value, name: &str, mode: SelectMode) -> Result<Value, ResolveError> {
78 let defs = schema.get("$defs").and_then(|d| d.as_object());
79 let present = defs.map(|d| d.contains_key(name)).unwrap_or(false);
80 if !present {
81 let available = defs
82 .map(|d| match mode {
83 SelectMode::Derived => d
86 .keys()
87 .filter(|k| k.ends_with("_request") || k.ends_with("_response"))
88 .cloned()
89 .collect::<Vec<_>>(),
90 SelectMode::Explicit => d.keys().cloned().collect::<Vec<_>>(),
91 })
92 .unwrap_or_default()
93 .join(", ");
94 return Err(match mode {
95 SelectMode::Derived => ResolveError::OperationShapeNotFound {
96 key: name.to_string(),
97 available,
98 },
99 SelectMode::Explicit => ResolveError::DefNotFound {
100 def: name.to_string(),
101 available,
102 },
103 });
104 }
105
106 let mut wrapper = Map::new();
107 if let Some(s) = schema.get("$schema") {
108 wrapper.insert("$schema".to_string(), s.clone());
109 }
110 wrapper.insert(
111 "$ref".to_string(),
112 Value::String(format!("#/$defs/{}", name)),
113 );
114 if let Some(defs) = schema.get("$defs") {
115 wrapper.insert("$defs".to_string(), defs.clone());
116 }
117 Ok(Value::Object(wrapper))
118}
119
120pub fn validate_against_schema(schema: &Value, payload: &Value) -> Result<(), ValidateError> {
125 let validator = jsonschema::validator_for(schema).map_err(|e| {
126 ValidateError::Resolve(ResolveError::InvalidSchema {
127 message: e.to_string(),
128 })
129 })?;
130
131 let errors: Vec<SchemaError> = validator
132 .iter_errors(payload)
133 .map(|e| SchemaError {
134 path: e.instance_path.to_string(),
135 message: e.to_string(),
136 })
137 .collect();
138
139 if errors.is_empty() {
140 Ok(())
141 } else {
142 Err(ValidateError::Invalid { errors })
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149 use crate::types::Direction;
150 use serde_json::json;
151
152 #[test]
153 fn validate_valid_payload() {
154 let schema = json!({
155 "type": "object",
156 "properties": {
157 "name": { "type": "string" }
158 },
159 "required": ["name"]
160 });
161 let payload = json!({ "name": "test" });
162 let options = ResolveOptions::new(Direction::Request, "create");
163
164 let result = validate(&schema, &payload, &options);
165 assert!(result.is_ok());
166 }
167
168 #[test]
169 fn validate_missing_required_field() {
170 let schema = json!({
171 "type": "object",
172 "properties": {
173 "name": { "type": "string", "ucp_request": "required" }
174 }
175 });
176 let payload = json!({});
177 let options = ResolveOptions::new(Direction::Request, "create");
178
179 let result = validate(&schema, &payload, &options);
180 assert!(matches!(result, Err(ValidateError::Invalid { .. })));
181 }
182
183 #[test]
184 fn validate_wrong_type() {
185 let schema = json!({
186 "type": "object",
187 "properties": {
188 "name": { "type": "string" }
189 }
190 });
191 let payload = json!({ "name": 123 });
192 let options = ResolveOptions::new(Direction::Request, "create");
193
194 let result = validate(&schema, &payload, &options);
195 assert!(matches!(result, Err(ValidateError::Invalid { .. })));
196 }
197
198 #[test]
199 fn validate_omitted_field_rejected() {
200 let schema = json!({
203 "type": "object",
204 "additionalProperties": false,
205 "properties": {
206 "id": { "type": "string", "ucp_request": "omit" },
207 "name": { "type": "string" }
208 }
209 });
210 let payload = json!({ "name": "test", "id": "123" });
211 let options = ResolveOptions::new(Direction::Request, "create");
212
213 let result = validate(&schema, &payload, &options);
214 assert!(matches!(result, Err(ValidateError::Invalid { .. })));
215 }
216
217 #[test]
218 fn validate_collects_multiple_errors() {
219 let schema = json!({
220 "type": "object",
221 "properties": {
222 "name": { "type": "string", "ucp_request": "required" },
223 "age": { "type": "number", "ucp_request": "required" }
224 }
225 });
226 let payload = json!({});
227 let options = ResolveOptions::new(Direction::Request, "create");
228
229 let result = validate(&schema, &payload, &options);
230 match result {
231 Err(ValidateError::Invalid { errors }) => {
232 assert_eq!(errors.len(), 2);
233 }
234 _ => panic!("expected validation error with 2 errors"),
235 }
236 }
237
238 #[test]
239 fn validate_allof_strict_accepts_properties_from_all_branches() {
240 let schema = json!({
243 "allOf": [
244 {
245 "type": "object",
246 "properties": {
247 "id": { "type": "string" }
248 }
249 },
250 {
251 "type": "object",
252 "properties": {
253 "name": { "type": "string" }
254 }
255 }
256 ]
257 });
258 let payload = json!({ "id": "123", "name": "test" });
260 let options = ResolveOptions::new(Direction::Request, "create").strict(true);
261
262 let result = validate(&schema, &payload, &options);
263 assert!(
264 result.is_ok(),
265 "should accept properties from all allOf branches"
266 );
267 }
268
269 #[test]
270 fn validate_allof_strict_rejects_unknown_properties() {
271 let schema = json!({
273 "allOf": [
274 {
275 "type": "object",
276 "properties": {
277 "id": { "type": "string" }
278 }
279 },
280 {
281 "type": "object",
282 "properties": {
283 "name": { "type": "string" }
284 }
285 }
286 ]
287 });
288 let payload = json!({ "id": "123", "name": "test", "unknown": "bad" });
290 let options = ResolveOptions::new(Direction::Request, "create").strict(true);
291
292 let result = validate(&schema, &payload, &options);
293 assert!(
294 matches!(result, Err(ValidateError::Invalid { .. })),
295 "should reject unknown properties in strict mode"
296 );
297 }
298
299 #[test]
300 fn validate_allof_non_strict_allows_unknown_properties() {
301 let schema = json!({
303 "allOf": [
304 {
305 "type": "object",
306 "properties": {
307 "id": { "type": "string" }
308 }
309 },
310 {
311 "type": "object",
312 "properties": {
313 "name": { "type": "string" }
314 }
315 }
316 ]
317 });
318 let payload = json!({ "id": "123", "name": "test", "unknown": "allowed" });
320 let options = ResolveOptions::new(Direction::Request, "create").strict(false);
321
322 let result = validate(&schema, &payload, &options);
323 assert!(
324 result.is_ok(),
325 "should allow unknown properties in non-strict mode"
326 );
327 }
328}