-
Notifications
You must be signed in to change notification settings - Fork 284
Expand file tree
/
Copy pathJsonSchema.fs
More file actions
494 lines (437 loc) · 19.5 KB
/
JsonSchema.fs
File metadata and controls
494 lines (437 loc) · 19.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
namespace FSharp.Data.Runtime
open System
open System.Globalization
open System.Collections.Generic
open FSharp.Data
open FSharp.Data.Runtime
open FSharp.Data.Runtime.StructuralTypes
open FSharp.Data.Runtime.StructuralInference
/// Module that handles JSON Schema parsing and type inference
module JsonSchema =
/// Represents the result of validating a JSON value against a schema
type ValidationResult =
| Valid
| Invalid of string
/// Represents a JSON Schema validator function
type JsonSchemaValidator = JsonValue -> ValidationResult
/// Represents basic JSON Schema types
type JsonSchemaType =
| String
| Number
| Integer
| Boolean
| Object
| Array
| Null
| Any
/// Represents a parsed JSON Schema
type JsonSchemaDefinition =
{ Type: JsonSchemaType
Description: string option
Properties: Map<string, JsonSchemaDefinition> option
Required: string list option
Items: JsonSchemaDefinition option
Enum: JsonValue list option
Minimum: decimal option
Maximum: decimal option
MinLength: int option
MaxLength: int option
Format: string option
Pattern: string option
OneOf: JsonSchemaDefinition list option
AnyOf: JsonSchemaDefinition list option
AllOf: JsonSchemaDefinition list option
Reference: string option }
/// Default empty schema definition
let empty =
{ Type = Any
Description = None
Properties = None
Required = None
Items = None
Enum = None
Minimum = None
Maximum = None
MinLength = None
MaxLength = None
Format = None
Pattern = None
OneOf = None
AnyOf = None
AllOf = None
Reference = None }
/// Convert JSON Schema format to .NET type
let formatToType (format: string) =
match format.ToLowerInvariant() with
| "date-time"
| "date"
| "time" -> typeof<DateTime>
| "email"
| "hostname"
| "ipv4"
| "ipv6"
| "uri" -> typeof<string>
| "uuid"
| "guid" -> typeof<Guid>
| "int32"
| "int64" -> typeof<int>
| "float"
| "double" -> typeof<float>
| _ -> typeof<string>
/// Parse a JSON Schema from a JsonValue
let rec parseSchema (schemaJson: JsonValue) =
let getStringProp name =
if schemaJson.TryGetProperty(name).IsSome then
match schemaJson.[name] with
| JsonValue.String s -> Some s
| _ -> None
else
None
let getNumberProp name =
if schemaJson.TryGetProperty(name).IsSome then
match schemaJson.[name] with
| JsonValue.Number n -> Some n
| _ -> None
else
None
let getIntProp name = getNumberProp name |> Option.map int
let getType () =
if schemaJson.TryGetProperty("type").IsSome then
match schemaJson.["type"] with
| JsonValue.String "string" -> String
| JsonValue.String "number" -> Number
| JsonValue.String "integer" -> Integer
| JsonValue.String "boolean" -> Boolean
| JsonValue.String "object" -> Object
| JsonValue.String "array" -> Array
| JsonValue.String "null" -> Null
| JsonValue.Array types ->
// If a type is an array, take the first non-null type
types
|> Array.tryPick (function
| JsonValue.String "string" -> Some String
| JsonValue.String "number" -> Some Number
| JsonValue.String "integer" -> Some Integer
| JsonValue.String "boolean" -> Some Boolean
| JsonValue.String "object" -> Some Object
| JsonValue.String "array" -> Some Array
| _ -> None)
|> Option.defaultValue Any
| _ -> Any
else
Any
let getEnum () =
if schemaJson.TryGetProperty("enum").IsSome then
match schemaJson.["enum"] with
| JsonValue.Array values -> Some(values |> Array.toList)
| _ -> None
else
None
let getRequired () =
if schemaJson.TryGetProperty("required").IsSome then
match schemaJson.["required"] with
| JsonValue.Array values ->
values
|> Array.choose (function
| JsonValue.String s -> Some s
| _ -> None)
|> Array.toList
|> Some
| _ -> None
else
None
let getProperties () =
if schemaJson.TryGetProperty("properties").IsSome then
match schemaJson.["properties"] with
| JsonValue.Record properties ->
properties
|> Array.map (fun (name, schema) -> name, parseSchema schema)
|> Map.ofArray
|> Some
| _ -> None
else
None
let getItems () =
if schemaJson.TryGetProperty("items").IsSome then
match schemaJson.["items"] with
| JsonValue.Record _ as itemSchema -> Some(parseSchema itemSchema)
| JsonValue.Array schemas when schemas.Length > 0 ->
// For tuple schemas, just use the first schema
Some(parseSchema schemas.[0])
| _ -> None
else
None
let getOneOf () =
if schemaJson.TryGetProperty("oneOf").IsSome then
match schemaJson.["oneOf"] with
| JsonValue.Array schemas -> schemas |> Array.map parseSchema |> Array.toList |> Some
| _ -> None
else
None
let getAnyOf () =
if schemaJson.TryGetProperty("anyOf").IsSome then
match schemaJson.["anyOf"] with
| JsonValue.Array schemas -> schemas |> Array.map parseSchema |> Array.toList |> Some
| _ -> None
else
None
let getAllOf () =
if schemaJson.TryGetProperty("allOf").IsSome then
match schemaJson.["allOf"] with
| JsonValue.Array schemas -> schemas |> Array.map parseSchema |> Array.toList |> Some
| _ -> None
else
None
let getReference () =
if schemaJson.TryGetProperty("$ref").IsSome then
match schemaJson.["$ref"] with
| JsonValue.String ref -> Some ref
| _ -> None
else
None
{ Type = getType ()
Description = getStringProp "description"
Properties = getProperties ()
Required = getRequired ()
Items = getItems ()
Enum = getEnum ()
Minimum = getNumberProp "minimum"
Maximum = getNumberProp "maximum"
MinLength = getIntProp "minLength"
MaxLength = getIntProp "maxLength"
Format = getStringProp "format"
Pattern = getStringProp "pattern"
OneOf = getOneOf ()
AnyOf = getAnyOf ()
AllOf = getAllOf ()
Reference = getReference () }
/// Parse a JSON Schema from a string
let parseSchemaFromString (schemaString: string) =
JsonValue.Parse(schemaString) |> parseSchema
/// Parse a JSON Schema from a TextReader
let parseSchemaFromTextReader (resolutionFolder: string) (reader: System.IO.TextReader) =
let schemaString = reader.ReadToEnd()
parseSchemaFromString schemaString
// Helper functions to create InferedType values
let createStringType optional =
InferedType.Primitive(typeof<string>, None, optional, false)
let createIntType optional =
InferedType.Primitive(typeof<int>, None, optional, false)
let createDecimalType optional =
InferedType.Primitive(typeof<decimal>, None, optional, false)
let createBooleanType optional =
InferedType.Primitive(typeof<bool>, None, optional, false)
let createDateTimeType optional =
InferedType.Primitive(typeof<DateTime>, None, optional, false)
let createGuidType optional =
InferedType.Primitive(typeof<Guid>, None, optional, false)
/// Convert a JSON Schema type to an InferedType for the type provider
let rec schemaToInferedType (umps: IUnitsOfMeasureProvider) (schema: JsonSchemaDefinition) =
match schema.Type with
| String ->
match schema.Format with
| Some format ->
match format.ToLowerInvariant() with
| "date-time"
| "date" -> createDateTimeType false
| "uuid"
| "guid" -> createGuidType false
| _ -> createStringType false
| None -> createStringType false
| Number -> createDecimalType false
| Integer -> createIntType false
| Boolean -> createBooleanType false
| Object ->
match schema.Properties with
| Some props ->
let properties =
props
|> Map.toArray
|> Array.map (fun (name, propSchema) ->
let isOptional =
match schema.Required with
| Some required -> not (List.contains name required)
| None -> true
let propType = schemaToInferedType umps propSchema
// Create property with the appropriate type and optionality
{ Name = name
Type =
if isOptional then
propType.EnsuresHandlesMissingValues false
else
propType })
|> Array.toList
InferedType.Record(None, properties, false)
| None -> InferedType.Record(None, [], false)
| Array ->
match schema.Items with
| Some itemSchema ->
let elementType = schemaToInferedType umps itemSchema
let tag = typeTag elementType
let order = [ tag ]
let types = Map.ofList [ (tag, (InferedMultiplicity.Multiple, elementType)) ]
InferedType.Collection(order, types)
| None ->
let order = [ InferedTypeTag.Null ]
let types =
Map.ofList [ (InferedTypeTag.Null, (InferedMultiplicity.Multiple, InferedType.Top)) ]
InferedType.Collection(order, types)
| Null -> InferedType.Null
| Any -> InferedType.Top
/// Resolve references in a schema (simple implementation)
let resolveReferences (schema: JsonSchemaDefinition) (rootSchema: JsonValue) =
// This is a simplified implementation - a complete one would handle JSON pointers properly
let rec resolveRef (refPath: string) =
match refPath with
| path when path.StartsWith("#/", StringComparison.Ordinal) ->
// Handle local references like "#/definitions/Point"
let parts = path.Substring(2).Split('/')
let rec navigate current parts =
match parts with
| [||] -> current
| _ ->
match current with
| JsonValue.Record fields ->
match Array.tryFind (fun (name, _) -> name = parts.[0]) fields with
| Some(_, value) -> navigate value parts.[1..]
| None -> failwith $"Reference part '{parts.[0]}' not found"
| _ -> failwith "Invalid reference path"
let referencedValue = navigate rootSchema parts
parseSchema referencedValue
| _ -> failwith $"Only local references are supported: {refPath}"
let rec resolve (schema: JsonSchemaDefinition) =
match schema.Reference with
| Some refPath -> resolveRef refPath
| None ->
// Also resolve references in nested schemas
let resolvedProperties =
schema.Properties |> Option.map (Map.map (fun _ v -> resolve v))
let resolvedItems = schema.Items |> Option.map resolve
let resolvedOneOf = schema.OneOf |> Option.map (List.map resolve)
let resolvedAnyOf = schema.AnyOf |> Option.map (List.map resolve)
let resolvedAllOf = schema.AllOf |> Option.map (List.map resolve)
{ schema with
Properties = resolvedProperties
Items = resolvedItems
OneOf = resolvedOneOf
AnyOf = resolvedAnyOf
AllOf = resolvedAllOf
Reference = None }
resolve schema
/// Validate a JSON value against a schema
let rec validate (schema: JsonSchemaDefinition) (value: JsonValue) : ValidationResult =
// Check nulls first
if value = JsonValue.Null then
match schema.Type with
| Null -> Valid
| _ -> Invalid "Expected a non-null value"
else
match schema.Type with
| String ->
match value with
| JsonValue.String str ->
// Validate string constraints
match schema.MinLength, schema.MaxLength with
| Some minLen, Some maxLen when str.Length < minLen || str.Length > maxLen ->
Invalid $"String length must be between {minLen} and {maxLen}"
| Some minLen, None when str.Length < minLen -> Invalid $"String length must be at least {minLen}"
| None, Some maxLen when str.Length > maxLen -> Invalid $"String length must be at most {maxLen}"
| _ ->
// Validate pattern
match schema.Pattern with
| Some pattern ->
let regex = System.Text.RegularExpressions.Regex(pattern)
if regex.IsMatch(str) then
Valid
else
Invalid $"String does not match pattern: {pattern}"
| None -> Valid
| _ -> Invalid "Expected a string value"
| Number ->
match value with
| JsonValue.Number num ->
// Validate number constraints
match schema.Minimum, schema.Maximum with
| Some min, Some max when num < min || num > max ->
Invalid $"Number must be between {min} and {max}"
| Some min, None when num < min -> Invalid $"Number must be at least {min}"
| None, Some max when num > max -> Invalid $"Number must be at most {max}"
| _ -> Valid
| _ -> Invalid "Expected a number value"
| Integer ->
match value with
| JsonValue.Number num ->
// Check if it's an integer
if Math.Round(num) <> num then
Invalid "Expected an integer value"
else
// Validate integer constraints
match schema.Minimum, schema.Maximum with
| Some min, Some max when num < min || num > max ->
Invalid $"Integer must be between {min} and {max}"
| Some min, None when num < min -> Invalid $"Integer must be at least {min}"
| None, Some max when num > max -> Invalid $"Integer must be at most {max}"
| _ -> Valid
| _ -> Invalid "Expected an integer value"
| Boolean ->
match value with
| JsonValue.Boolean _ -> Valid
| _ -> Invalid "Expected a boolean value"
| Object ->
match value with
| JsonValue.Record properties ->
// Validate required properties
match schema.Required with
| Some requiredProps ->
let missingProps =
requiredProps
|> List.filter (fun prop ->
properties |> Array.exists (fun (name, _) -> name = prop) |> not)
if missingProps.Length > 0 then
let missingPropsStr = String.concat ", " missingProps
Invalid $"Missing required properties: {missingPropsStr}"
else
// Validate property values
match schema.Properties with
| Some propSchemas ->
let propResults =
properties
|> Array.choose (fun (name, propValue) ->
match Map.tryFind name propSchemas with
| Some propSchema ->
match validate propSchema propValue with
| Valid -> None
| Invalid msg -> Some($"Property '{name}': {msg}")
| None -> None // Allow additional properties
)
if propResults.Length > 0 then
Invalid(String.concat ", " propResults)
else
Valid
| None -> Valid
| None -> Valid
| _ -> Invalid "Expected an object value"
| Array ->
match value with
| JsonValue.Array items ->
// Validate array items
match schema.Items with
| Some itemSchema ->
let itemResults =
items
|> Array.mapi (fun idx item ->
match validate itemSchema item with
| Valid -> None
| Invalid msg -> Some($"Item {idx}: {msg}"))
|> Array.choose id
if itemResults.Length > 0 then
Invalid(String.concat ", " itemResults)
else
Valid
| None -> Valid
| _ -> Invalid "Expected an array value"
| Null -> Invalid "Expected a null value"
| Any -> Valid
/// Create a validator function from a schema
let createValidator (schema: JsonSchemaDefinition) : JsonSchemaValidator =
fun jsonValue -> validate schema jsonValue