19
19
import yaml_source_map as ymap
20
20
from yaml_source_map .errors import InvalidYamlError
21
21
22
+ from oascomply import schema_catalog
22
23
from oascomply .oasgraph import (
23
24
OasGraph , OasGraphResult , OUTPUT_FORMATS_LINE , OUTPUT_FORMATS_STRUCTURED ,
24
25
)
25
26
from oascomply .schemaparse import Annotation , SchemaParser
27
+ from oascomply .oas30dialect import (
28
+ OasJson , OasJsonTypeError , OasJsonRefSuffixError , OAS30_DIALECT_METASCHEMA ,
29
+ )
26
30
import oascomply .resourceid as rid
27
31
28
32
__all__ = [
@@ -134,6 +138,7 @@ def add_resource(
134
138
path : Optional [Path ] = None ,
135
139
url : Optional [str ] = None ,
136
140
sourcemap : Optional [Mapping ] = None ,
141
+ oastype : Optional [str ] = None ,
137
142
) -> None :
138
143
"""
139
144
Add a resource as part of the API description, and set its URI
@@ -148,14 +153,55 @@ def add_resource(
148
153
uri = rid .Iri (uri )
149
154
assert uri .fragment is None , "Only complete documenets can be added."
150
155
151
- # The jschon.JSON class keeps track of JSON Pointer values for
152
- # every data entry, as well as providing parent links and type
153
- # information.
154
- self ._contents [uri ] = jschon .JSON (document )
156
+ logger .info (f'Adding document "{ path } " ...' )
157
+ logger .info (f'...URL <{ url } >' )
158
+ logger .info (f'...URI <{ uri } >' )
159
+ if oastype and oastype == 'Schema' :
160
+ logger .info (f'...instantiating JSON Schema <{ uri } >' )
161
+ self ._contents [uri ] = jschon .JSONSchema (
162
+ document ,
163
+ uri = jschon .URI (str (uri )),
164
+ metaschema_uri = jschon .URI (OAS30_DIALECT_METASCHEMA ),
165
+ )
166
+ # assert isinstance(
167
+ else :
168
+ # The jschon.JSON class keeps track of JSON Pointer values for
169
+ # every data entry, as well as providing parent links and type
170
+ # information. The OasJson subclass also automatically
171
+ # instantiates jschon.JSONSchema classes for Schema Objects
172
+ # and (in 3.0) for Reference Objects occupying the place of
173
+ # Schema Objects.
174
+ logger .info (f'...instantiating OAS Document <{ uri } >' )
175
+ self ._contents [uri ] = OasJson (
176
+ document ,
177
+ uri = uri ,
178
+ url = url ,
179
+ oasversion = self ._version [:3 ],
180
+ )
155
181
if sourcemap :
156
182
self ._sources [uri ] = sourcemap
157
183
self ._g .add_resource (url , uri , filename = path .name )
158
184
185
+ def resolve_references (self ):
186
+ for document in self ._contents .values ():
187
+ logger .info (
188
+ f'Checking JSON Schema references in <{ document .uri } >...' ,
189
+ )
190
+ if isinstance (document , OasJson ):
191
+ logger .info (
192
+ '...resolving with OasJson.resolve_references()' ,
193
+ )
194
+ document .resolve_references ()
195
+ elif isinstance (document , jschon .JSONSchema ):
196
+ logger .info (
197
+ '...already resolved by jschon.JSONSchema()' ,
198
+ )
199
+ else :
200
+ logger .warning (
201
+ f'Unknown type "{ type (document )} " '
202
+ f'for document <{ document .uri } >' ,
203
+ )
204
+
159
205
def get_resource (self , uri : Union [str , rid .Iri ]) -> Optional [Any ]:
160
206
if not isinstance (uri , rid .IriWithJsonPtr ):
161
207
# TODO: IRI vs URI
@@ -172,10 +218,11 @@ def get_resource(self, uri: Union[str, rid.Iri]) -> Optional[Any]:
172
218
)
173
219
except (KeyError , jschon .JSONPointerError ):
174
220
logger .warning (f"Could not find resource { uri } " )
175
- return None , None , None
221
+ raise # return None, None, None
176
222
177
223
def validate (self , resource_uri = None , oastype = 'OpenAPI' ):
178
224
sp = SchemaParser .get_parser ({}, annotations = ANNOT_ORDER )
225
+ errors = []
179
226
if resource_uri is None :
180
227
assert oastype == 'OpenAPI'
181
228
resource_uri = self ._primary_uri
@@ -206,17 +253,21 @@ def validate(self, resource_uri=None, oastype='OpenAPI'):
206
253
if annot == 'oasExamples' :
207
254
# By this point we have set up the necessary reference info
208
255
for uri , oastype in to_validate .items ():
209
- # TODO: Handle fragments vs whole resources
210
256
if uri not in self ._validated :
211
- self .validate (uri , oastype )
257
+ errors . extend ( self .validate (uri , oastype ) )
212
258
213
259
method_name = f'add_{ annot .lower ()} '
214
260
method_callable = getattr (self ._g , method_name )
215
261
for args in by_method [method_name ]:
216
262
graph_result = method_callable (* args )
263
+ for err in graph_result .errors :
264
+ errors .append (err )
265
+ logger .error (json .dumps (err ['error' ], indent = 2 ))
217
266
for uri , oastype in graph_result .refTargets :
218
267
to_validate [uri ] = oastype
219
268
269
+ return errors
270
+
220
271
def serialize (
221
272
self ,
222
273
* args ,
@@ -261,7 +312,7 @@ def serialize(
261
312
new_kwargs .update (kwargs )
262
313
263
314
if destination in (sys .stdout , sys .stderr ):
264
- # rdflib serializers write bytes, not str # if destination
315
+ # rdflib serializers write bytes, not str if destination
265
316
# is not None, which doesn't work with sys.stdout / sys.stderr
266
317
destination .flush ()
267
318
with os .fdopen (
@@ -281,36 +332,78 @@ def serialize(
281
332
self ._g .serialize (* args , destination = destination , ** new_kwargs )
282
333
283
334
@classmethod
284
- def _process_file_arg (cls , filearg , prefixes , create_source_map ):
335
+ def _process_file_arg (
336
+ cls ,
337
+ filearg ,
338
+ prefixes ,
339
+ create_source_map ,
340
+ strip_suffix ,
341
+ ):
285
342
path = Path (filearg [0 ])
286
343
full_path = path .resolve ()
344
+ oastype = None
345
+ uri = None
346
+ logger .debug (
347
+ f'Processing { full_path !r} , strip_suffix={ strip_suffix } ...'
348
+ )
287
349
if len (filearg ) > 1 :
288
- # TODO: Support semantic type
289
- uri = filearg [1 ]
290
- else :
291
- uri = full_path .with_suffix ('' ).as_uri ()
350
+ try :
351
+ uri = rid .IriWithJsonPtr (filearg [1 ])
352
+ logger .debug (f'...assigning URI <{ uri } > from 2nd arg' )
353
+ except ValueError :
354
+ # TODO: Verify OAS type
355
+ oastype = filearg [1 ]
356
+ logger .debug (f'...assigning OAS type "{ oastype } " from 2nd arg' )
357
+ if len (filearg ) > 2 :
358
+ if uri is None :
359
+ raise ValueError ('2nd of 3 -f args must be URI' )
360
+ oastype = filearg [2 ]
361
+ logger .debug (f'...assigning OAS type "{ oastype } " from 3rd arg' )
362
+
292
363
for p in prefixes :
293
364
try :
294
365
rel = full_path .relative_to (p .directory )
295
366
uri = rid .Iri (str (p .prefix ) + str (rel .with_suffix ('' )))
367
+ logger .debug (
368
+ f'...assigning URI <{ uri } > using prefix <{ p .prefix } >' ,
369
+ )
296
370
except ValueError :
297
371
pass
372
+
298
373
filetype = path .suffix [1 :] or 'yaml'
299
374
if filetype == 'yml' :
300
375
filetype = 'yaml'
376
+ logger .debug ('...determined filetype={filetype}' )
377
+
378
+ if uri is None :
379
+ if strip_suffix :
380
+ uri = rid .Iri (full_path .with_suffix ('' ).as_uri ())
381
+ else :
382
+ uri = rid .Iri (full_path .as_uri ())
383
+ logger .debug (
384
+ f'...assigning URI <{ uri } > from URL <{ full_path .as_uri ()} >' ,
385
+ )
301
386
302
387
content = path .read_text ()
303
388
sourcemap = None
304
389
if filetype == 'json' :
305
390
data = json .loads (content )
306
391
if create_source_map :
392
+ logger .info (
393
+ f'Creating JSON sourcemap for { path } , '
394
+ '(can disable with -n if slow)' ,
395
+ )
307
396
sourcemap = jmap .calculate (content )
308
397
elif filetype == 'yaml' :
309
398
data = yaml .safe_load (content )
310
399
if create_source_map :
311
400
# The YAML source mapper gets confused sometimes,
312
401
# just log a warning and work without the map.
313
402
try :
403
+ logger .info (
404
+ f'Creating YAML sourcemap for { path } , '
405
+ '(can disable with -n if slow)' ,
406
+ )
314
407
sourcemap = ymap .calculate (content )
315
408
except InvalidYamlError :
316
409
logger .warn (
@@ -325,6 +418,7 @@ def _process_file_arg(cls, filearg, prefixes, create_source_map):
325
418
'sourcemap' : sourcemap ,
326
419
'path' : path ,
327
420
'uri' : uri ,
421
+ 'oastype' : oastype ,
328
422
}
329
423
330
424
@classmethod
@@ -364,6 +458,19 @@ def _process_prefix(cls, p):
364
458
)
365
459
return UriPrefix (prefix = prefix , directory = path )
366
460
461
+ @classmethod
462
+ def _url_for (cls , uri ):
463
+ if uri .scheme != 'file' :
464
+ return None
465
+ path = Path (uri .path )
466
+ if path .exists ():
467
+ return uri
468
+ for suffix in ('.json' , '.yaml' , '.ym' ):
469
+ ps = path .with_suffix (suffix )
470
+ if ps .exists ():
471
+ return rid .Iri (ps .as_uri ())
472
+ return None
473
+
367
474
@classmethod
368
475
def load (cls ):
369
476
class CustomArgumentParser (argparse .ArgumentParser ):
@@ -433,10 +540,9 @@ def format_help(self):
433
540
'-x' ,
434
541
'--strip-suffix' ,
435
542
nargs = '?' ,
436
- type = bool ,
437
- default = None ,
438
- help = "NOT YET IMPLEMENTED "
439
- "Assign URIs to documents by stripping the file extension "
543
+ choices = ('auto' , 'yes' , 'no' ),
544
+ default = 'auto' ,
545
+ help = "Assign URIs to documents by stripping the file extension "
440
546
"from their URLs if they have not been assigned URIs by "
441
547
"-d or the two-argument form of -f; can be set to false "
442
548
"to *disable* prefix-stripping by -d"
@@ -486,6 +592,13 @@ def format_help(self):
486
592
help = "NOT YET IMPLEMENTED "
487
593
"TODO: Support storing to various kinds of databases." ,
488
594
)
595
+ parser .add_argument (
596
+ '-v' ,
597
+ '--verbose' ,
598
+ action = 'count' ,
599
+ default = 0 ,
600
+ help = "Increase verbosity; can passed twice for full debug output." ,
601
+ )
489
602
parser .add_argument (
490
603
'--test-mode' ,
491
604
action = 'store_true' ,
@@ -495,6 +608,26 @@ def format_help(self):
495
608
"automated testing of the entire system." ,
496
609
)
497
610
args = parser .parse_args ()
611
+ if args .verbose :
612
+ if args .verbose == 1 :
613
+ logging .basicConfig (level = logging .INFO )
614
+ else :
615
+ logging .basicConfig (level = logging .DEBUG )
616
+ else :
617
+ logging .basicConfig (level = logging .WARN )
618
+
619
+ if args .strip_suffix is None :
620
+ # TODO: Write a custom arg action
621
+ # For now this simulates '-x' without an arg
622
+ # as equivalent to '-x yes' in the debug log.
623
+ args .strip_suffix = 'yes'
624
+ strip_suffix = {
625
+ 'auto' : None ,
626
+ 'yes' : True ,
627
+ 'no' : False ,
628
+ }[args .strip_suffix ]
629
+ logger .debug (f'Processed arguments:\n { args } ' )
630
+
498
631
if args .directories :
499
632
raise NotImplementedError ('-D option not yet implemented' )
500
633
@@ -509,18 +642,13 @@ def format_help(self):
509
642
filearg ,
510
643
prefixes ,
511
644
args .number_lines is True ,
645
+ strip_suffix ,
512
646
) for filearg in args .files ]
513
647
514
648
candidates = list (filter (lambda r : 'openapi' in r ['data' ], resources ))
515
649
if not candidates :
516
650
logger .error ("No document contains an 'openapi' field!" )
517
651
return - 1
518
- if len (candidates ) > 1 :
519
- logger .error (
520
- "Multiple documents with an 'openapi' field "
521
- "not yet supported"
522
- )
523
- return - 1
524
652
primary = candidates [0 ]
525
653
526
654
desc = ApiDescription (
@@ -537,10 +665,63 @@ def format_help(self):
537
665
r ['uri' ],
538
666
path = r ['path' ],
539
667
sourcemap = r ['sourcemap' ],
668
+ oastype = r ['oastype' ],
540
669
)
541
- logger .info (f"Adding document { r ['path' ]!r} <{ r ['uri' ]} >" )
670
+ try :
671
+ desc .resolve_references ()
672
+ errors = desc .validate ()
673
+ if errors :
674
+ sys .stderr .write ('\n API description contains errors\n \n ' )
675
+ sys .exit (- 1 )
676
+
677
+ except OasJsonRefSuffixError as e :
678
+ path = Path (e .target_resource_uri .path ).relative_to (Path .cwd ())
679
+ logger .error (
680
+ f'{ e .args [0 ]} \n \n '
681
+ 'The above error can be fixed either by using -x:'
682
+ f'\n \n \t -x -f { path } \n \n '
683
+ '... or by using the two-argument form of -f:'
684
+ f'\n \n \t -f { path } { e .ref_resource_uri } \n '
685
+ )
686
+ sys .exit (- 1 )
687
+
688
+ except OasJsonTypeError as e :
689
+ url = cls ._url_for (e .uri ) if e .url is None else e .url
690
+ if url is None :
691
+ logger .error (
692
+ f'Cannot determine URL and path for URI <{ e .uri } >, '
693
+ f'run with -v and check the logs' ,
694
+ )
695
+ url = rid .Iri ('about:unknown-url' )
696
+ path = '<unknown-path>'
697
+ else :
698
+ path = Path (url .path ).relative_to (Path .cwd ())
699
+
700
+ # TODO: This isn't always quite right depending on -d / -D
701
+ # when strip_suffix is None
702
+ if strip_suffix in (True , None ):
703
+ uri_len = len (str (e .uri ))
704
+ truncated_url = str (url )[:uri_len ]
705
+ missing_suffix = str (url )[uri_len :]
706
+ if (
707
+ truncated_url == str (e .uri ) and
708
+ missing_suffix in ('.json' , '.yaml' , '.yml' )
709
+ ):
710
+ path_and_uri = f'-x -f { path } '
711
+
712
+ if path_and_uri is None :
713
+ path_and_uri = (
714
+ f'-f { path } ' if e .uri == url
715
+ else f'-f { path } { e .uri } '
716
+ )
717
+
718
+ logger .error (
719
+ f'JSON Schema documents must pass "Schema" (without quotes) '
720
+ f'as an additional -f argument:\n \n '
721
+ f'\t { path_and_uri } Schema\n '
722
+ )
723
+ sys .exit (- 1 )
542
724
543
- desc .validate ()
544
725
if args .output_format is not None or args .test_mode is True :
545
726
desc .serialize (output_format = args .output_format )
546
727
else :
0 commit comments