Skip to content

Commit bd8d6ec

Browse files
committed
Fist working version for simple schemas
1 parent ce6b2c7 commit bd8d6ec

File tree

5 files changed

+254
-7
lines changed

5 files changed

+254
-7
lines changed

ogc/bblocks/extension.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import os
2+
from dataclasses import dataclass, field
3+
from typing import Callable, Any
4+
5+
from ogc.na.annotate_schema import SchemaResolver, ReferencedSchema
6+
7+
from ogc.bblocks.models import BuildingBlock, BuildingBlockRegister
8+
9+
10+
@dataclass
11+
class SchemaNode:
12+
tag: str | None
13+
root: 'SchemaNode | None' = None
14+
preserve_branch = False
15+
parent: 'SchemaNode | None' = None
16+
is_properties: bool = False
17+
subschema: dict | list | None = None
18+
children: list['SchemaNode'] = field(default_factory=list)
19+
20+
def mark_preserve_branch(self):
21+
n = self
22+
while n is not None:
23+
if n.preserve_branch:
24+
break
25+
n.preserve_branch = True
26+
n = n.parent
27+
28+
29+
def process_extension(bblock: BuildingBlock, register: BuildingBlockRegister,
30+
parent_id: str, extensions: dict[str, str],
31+
ref_mapper: Callable[[str, Any], str] | None = None):
32+
33+
schema_resolver = register.schema_resolver
34+
all_register_files = {
35+
**register.imported_bblock_files,
36+
**register.local_bblock_files,
37+
}
38+
39+
if '#' in parent_id or any('#' in k or '#' in v for k, v in extensions.items()):
40+
raise ValueError('Extension points can only be declared for building blocks, not for fragments. '
41+
'Please check that your extension point declarations contain no fragment identifiers ("#")')
42+
43+
extension_target_schemas = {}
44+
for extension_bblock_id in extensions.values():
45+
local_bblock = register.bblocks.get(extension_bblock_id)
46+
if local_bblock:
47+
if local_bblock.annotated_schema:
48+
extension_target_schemas[extension_bblock_id] = os.path.relpath(
49+
local_bblock.annotated_schema.resolve(),
50+
bblock.annotated_path.resolve()
51+
)
52+
# TODO: OpenAPI?
53+
else:
54+
imported_bblock = register.imported_bblocks[extension_bblock_id]
55+
imported_bblock_schema = imported_bblock.get('schema', {}).get('application/yaml')
56+
if imported_bblock_schema:
57+
extension_target_schemas[extension_bblock_id] = imported_bblock_schema
58+
59+
# TODO: OpenAPI?
60+
61+
visited_refs = set()
62+
schema_branches: list[SchemaNode] = []
63+
64+
def create_schema_node(parent_node: SchemaNode | None, tag: str, is_properties: bool = False,
65+
subschema: dict | list | None = None) -> SchemaNode:
66+
if parent_node is None:
67+
node = SchemaNode(tag=tag, is_properties=is_properties, subschema=subschema)
68+
node.root = node
69+
schema_branches.append(node)
70+
else:
71+
node = SchemaNode(root=parent_node.root, parent=parent_node, tag=tag,
72+
is_properties=is_properties, subschema=subschema)
73+
parent_node.children.append(node)
74+
return node
75+
76+
def walk_subschema(subschema, from_schema: ReferencedSchema, parent_node: SchemaNode | None):
77+
if not subschema or not isinstance(subschema, dict):
78+
return
79+
80+
if '$ref' in subschema:
81+
if ref_mapper:
82+
subschema['$ref'] = ref_mapper(subschema['$ref'], subschema)
83+
target_schema = schema_resolver.resolve_schema(subschema['$ref'], from_schema)
84+
85+
extension_target: str | None = None
86+
extension_source: str | None = None
87+
extension_target_id : str | None = None
88+
if (target_schema.location in all_register_files
89+
and all_register_files[target_schema.location] in extensions):
90+
extension_source = all_register_files[target_schema.location]
91+
extension_target_id = extensions[extension_source]
92+
extension_target = extension_target_schemas.get(extension_target_id)
93+
if not extension_target:
94+
raise ValueError(f'No schema could be found for extension target {extension_target_id}')
95+
96+
if extension_target:
97+
ref_node = create_schema_node(parent_node, '$ref')
98+
ref_node.subschema = {
99+
'$ref': str(extension_target),
100+
'x-bblocks-extension-source': extension_source,
101+
'x-bblocks-extension-target': extension_target_id,
102+
}
103+
ref_node.mark_preserve_branch()
104+
return
105+
else:
106+
# Avoid infinite loops
107+
target_schema_full_ref = (f"{target_schema.location}#{target_schema.fragment}"
108+
if target_schema.fragment
109+
else target_schema.location)
110+
if target_schema_full_ref in visited_refs:
111+
return
112+
visited_refs.add(target_schema_full_ref)
113+
114+
if target_schema:
115+
walk_subschema(target_schema.subschema, target_schema, parent_node)
116+
117+
for p in ('oneOf', 'allOf', 'anyOf'):
118+
collection = subschema.get(p)
119+
if collection and isinstance(collection, list):
120+
col_node = (create_schema_node(parent_node, p, subschema=collection)
121+
if p != 'allOf' else parent_node)
122+
for entry in collection:
123+
walk_subschema(entry, from_schema, col_node)
124+
125+
for i in ('prefixItems', 'items', 'contains', 'then', 'else', 'additionalProperties'):
126+
l = subschema.get(i)
127+
if isinstance(l, dict):
128+
entry_node = create_schema_node(parent_node, i, subschema=l)
129+
walk_subschema(l, from_schema, entry_node)
130+
131+
if 'properties' in subschema:
132+
properties_node = create_schema_node(parent_node, tag='properties')
133+
for prop_name, prop_schema in subschema['properties'].items():
134+
prop_node = create_schema_node(parent_node=properties_node, tag=prop_name,
135+
is_properties=True, subschema=prop_schema)
136+
walk_subschema(prop_schema, from_schema, prop_node)
137+
138+
pattern_properties = subschema.get('patternProperties')
139+
if pattern_properties:
140+
pps_node = create_schema_node(parent_node, tag='patternProperties')
141+
for pp_k, pp in pattern_properties.items():
142+
if isinstance(pp, dict):
143+
pp_node = create_schema_node(pps_node, pp_k, is_properties=True)
144+
walk_subschema(pp, from_schema, pp_node)
145+
146+
parent_bblock = register.bblocks.get(parent_id)
147+
if parent_bblock:
148+
root_schema = schema_resolver.resolve_schema(parent_bblock.annotated_schema)
149+
else:
150+
imp_bblock = register.imported_bblocks.get(parent_id)
151+
if not imp_bblock:
152+
raise ValueError(f"Could not find building block with id {parent_id} in register or imports.")
153+
bblock_schemas = imp_bblock.get('schema', {})
154+
bblock_schema = bblock_schemas.get('application/yaml', bblock_schemas.get('application/json'))
155+
# TODO: OpenAPI?
156+
if not bblock_schema:
157+
raise ValueError(f"Could not find schema for building block with id {parent_id}"
158+
f" in register or imports.")
159+
root_schema = schema_resolver.resolve_schema(bblock_schema)
160+
161+
walk_subschema(root_schema.full_contents, root_schema, None)
162+
163+
output_schema = {
164+
'$schema': 'https://json-schema.org/draft/2020-12/schema',
165+
'x-bblocks-extends': parent_id,
166+
'x-bblocks-extensions': extensions,
167+
'allOf': [],
168+
}
169+
for branch in schema_branches:
170+
if not branch.preserve_branch:
171+
continue
172+
173+
def walk_branch(node: SchemaNode, parent_schema: dict, force_preserve_branch: bool = False):
174+
if not force_preserve_branch and not node.preserve_branch:
175+
return
176+
if node.tag == '$ref' and node.subschema:
177+
parent_schema.setdefault('allOf', []).append(node.subschema)
178+
elif node.tag in ('oneOf', 'anyOf', 'allOf'):
179+
col_schema = parent_schema.setdefault(node.tag, [])
180+
for child in node.children:
181+
child_schema = {}
182+
col_schema.append(child_schema)
183+
walk_branch(child, child_schema, force_preserve_branch=node.tag in ('oneOf', 'anyOf'))
184+
else:
185+
if not node.children:
186+
# End of the line, we append the full subschema
187+
parent_schema[node.tag] = node.subschema
188+
else:
189+
parent_schema[node.tag] = {}
190+
for child in node.children:
191+
walk_branch(child, parent_schema[node.tag], force_preserve_branch=force_preserve_branch)
192+
193+
branch_entry = {}
194+
output_schema['allOf'].append(branch_entry)
195+
walk_branch(branch, branch_entry)
196+
197+
return output_schema

ogc/bblocks/models.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,11 @@ def __init__(self,
504504
found_deps.add(deps)
505505
elif isinstance(deps, list):
506506
found_deps.update(deps)
507+
if bblock.extensionPoints:
508+
found_deps.add(bblock.extensionPoints['baseBuildingBlock'])
509+
found_deps.update(bblock.extensionPoints['extensions'].keys())
510+
found_deps.update(bblock.extensionPoints['extensions'].values())
511+
found_deps.discard(bblock.identifier)
507512
if found_deps:
508513
bblock.metadata['dependsOn'] = list(found_deps)
509514
dep_graph.add_node(bblock.identifier)

ogc/bblocks/postprocess.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@
1515
from ogc.na.exceptions import ContextLoadError
1616
from ogc.na.util import is_url, dump_yaml
1717

18+
from ogc.bblocks.extension import process_extension
1819
from ogc.bblocks.generate_docs import DocGenerator
1920
from ogc.bblocks.oas30 import oas31_to_oas30
2021
from ogc.bblocks.util import write_jsonld_context, CustomJSONEncoder, \
2122
PathOrUrl, get_git_repo_url
22-
from ogc.bblocks.schema import annotate_schema, resolve_all_schema_references
23+
from ogc.bblocks.schema import annotate_schema, resolve_all_schema_references, write_annotated_schema
2324
from ogc.bblocks.models import BuildingBlock, BuildingBlockRegister, ImportedBuildingBlocks, BuildingBlockError
2425
from ogc.bblocks.validate import validate_test_resources, report_to_html
2526
from ogc.bblocks.transform import apply_transforms, transformers
@@ -250,7 +251,19 @@ def do_postprocess(bblock: BuildingBlock, light: bool = False) -> bool:
250251
if filter_id is None or building_block.identifier == filter_id:
251252
if not steps or 'annotate' in steps:
252253

253-
if building_block.schema.exists:
254+
if building_block.extensionPoints:
255+
if building_block.schema.exists or building_block.openapi.exists:
256+
raise ValueError('Extension points are incompatible with schema and openapi definitions')
257+
extended_schema = process_extension(building_block, register=bbr,
258+
parent_id=building_block.extensionPoints['baseBuildingBlock'],
259+
extensions=building_block.extensionPoints['extensions'])
260+
261+
for annotated in write_annotated_schema(bblock=building_block, bblocks_register=bbr,
262+
annotated_schema=extended_schema,
263+
oas30_downcompile=schemas_oas30_downcompile):
264+
print(f" - {annotated}", file=sys.stderr)
265+
266+
elif building_block.schema.exists:
254267

255268
if building_block.schema.is_url:
256269
# Force caching remote file

ogc/bblocks/schema.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from ogc.bblocks.util import update_refs, PathOrUrl, BBLOCKS_REF_ANNOTATION
1717

1818
from typing import TYPE_CHECKING
19+
1920
if TYPE_CHECKING:
2021
from ogc.bblocks.models import BuildingBlockRegister, BuildingBlock
2122

@@ -124,6 +125,14 @@ def annotate_schema(bblock: BuildingBlock,
124125
# if schema_url and '$id' not in annotated_schema:
125126
# annotated_schema['$id'] = schema_url
126127

128+
return write_annotated_schema(bblock=bblock, bblocks_register=bblocks_register,
129+
annotated_schema=annotated_schema, oas30_downcompile=oas30_downcompile)
130+
131+
132+
def write_annotated_schema(bblock: BuildingBlock,
133+
bblocks_register: BuildingBlockRegister,
134+
annotated_schema,
135+
oas30_downcompile: bool = False):
127136
result = []
128137

129138
# YAML
@@ -135,14 +144,16 @@ def annotate_schema(bblock: BuildingBlock,
135144
potential_yaml_refs = {}
136145

137146
def update_json_ref(ref: str):
138-
if ref[0] == '#' or not is_url(ref):
147+
if ref[0] == '#':
139148
return ref
140149
if '#' in ref:
141150
ref, fragment = ref.split('#', 1)
142151
fragment = '#' + fragment
143152
else:
144153
fragment = ''
145-
if ref in bblocks_register.local_bblock_files or ref in bblocks_register.imported_bblock_files:
154+
rel_ref = ref if is_url(ref)\
155+
else str(bblock.annotated_path.joinpath(ref).resolve().relative_to(Path().resolve()))
156+
if rel_ref in bblocks_register.local_bblock_files or ref in bblocks_register.imported_bblock_files:
146157
return re.sub(r'\.yaml$', r'.json', ref) + fragment
147158
elif ref.endswith('.yaml'):
148159
potential_yaml_refs[ref] = True
@@ -161,18 +172,20 @@ def update_json_ref(ref: str):
161172
# OAS 3.0
162173
if oas30_downcompile:
163174
try:
164-
if base_url:
175+
if bblocks_register.base_url:
165176
oas30_schema_fn = annotated_schema_fn.with_stem('schema-oas3.0')
166177
dump_yaml(oas30.schema_to_oas30(annotated_schema_fn,
167-
urljoin(base_url, str(os.path.relpath(oas30_schema_fn))),
178+
urljoin(bblocks_register.base_url,
179+
str(os.path.relpath(oas30_schema_fn))),
168180
bblocks_register),
169181
oas30_schema_fn)
170182
result.append(oas30_schema_fn)
171183

172184
oas30_schema_json_fn = annotated_schema_json_fn.with_stem('schema-oas3.0')
173185
with open(oas30_schema_json_fn, 'w') as f:
174186
json.dump(oas30.schema_to_oas30(annotated_schema_json_fn,
175-
urljoin(base_url, str(os.path.relpath(oas30_schema_json_fn))),
187+
urljoin(bblocks_register.base_url,
188+
str(os.path.relpath(oas30_schema_json_fn))),
176189
bblocks_register), f, indent=2)
177190
result.append(oas30_schema_json_fn)
178191
except Exception as e:

ogc/bblocks/schemas/bblock.schema.yaml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,3 +246,22 @@ properties:
246246
type: array
247247
items:
248248
type: string
249+
extensionPoints:
250+
description: |
251+
Extension points can be defined using a base building block and declaring specializations for other building
252+
blocks referenced by the base one.
253+
type: object
254+
required:
255+
- baseBuildingBlock
256+
- extensions
257+
properties:
258+
baseBuildingBlock:
259+
description: Base building block to extend
260+
type: string
261+
extensions:
262+
description: Map of "source building block -> specialized building block" extensions
263+
type: object
264+
minProperties: 1
265+
patternProperties:
266+
'.*':
267+
type: string

0 commit comments

Comments
 (0)