geniespec 0.1.1.dev2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
geniespec/genie.py ADDED
@@ -0,0 +1,505 @@
1
+ from __future__ import annotations
2
+
3
+ import collections
4
+ import os
5
+ import pathlib
6
+ import typing
7
+
8
+ import jinja2
9
+ import jsonschema # type: ignore[import-untyped]
10
+ import yaml # type: ignore[import-untyped]
11
+ from core import logging
12
+ from core.exceptions import BadRequestException
13
+ from core.exceptions import KibaException
14
+
15
+ from geniespec.languages import BaseLanguageDefinition
16
+ from geniespec.languages import PythonLanguageDefinition
17
+ from geniespec.renderable_types import ActionEndpointComponent
18
+ from geniespec.renderable_types import AttributeEndpointComponent
19
+ from geniespec.renderable_types import EndpointContainer
20
+ from geniespec.renderable_types import ExceptionRenderableField
21
+ from geniespec.renderable_types import MappingRenderableType
22
+ from geniespec.renderable_types import ProcessableResource
23
+ from geniespec.renderable_types import RenderableApi
24
+ from geniespec.renderable_types import RenderableAttribute
25
+ from geniespec.renderable_types import RenderableEndpoint
26
+ from geniespec.renderable_types import RenderableException
27
+ from geniespec.renderable_types import RenderableField
28
+ from geniespec.renderable_types import RenderableMethod
29
+ from geniespec.renderable_types import RenderableOperation
30
+ from geniespec.renderable_types import RenderableParameter
31
+ from geniespec.renderable_types import RenderableResource
32
+ from geniespec.renderable_types import RenderableString
33
+ from geniespec.renderable_types import RenderableType
34
+ from geniespec.renderable_types import ResourceEndpointComponent
35
+ from geniespec.renderable_types import SingleResourceEndpointComponent
36
+ from geniespec.types import Attribute
37
+ from geniespec.types import BaseTransition
38
+ from geniespec.types import ComplexTypes
39
+ from geniespec.types import FieldSources
40
+ from geniespec.types import KibaApi
41
+ from geniespec.types import PrimitiveTypes
42
+ from geniespec.types import RequestField
43
+ from geniespec.types import Resource
44
+ from geniespec.types import ResponseField
45
+ from geniespec.types import ResponseTypes
46
+
47
+ SCHEMA_FILE_PATH = pathlib.Path(__file__).parent / 'api_schema.yaml'
48
+ TEMPLATES_DIRECTORY_PATH = pathlib.Path(__file__).parent / 'templates'
49
+
50
+
51
+ class SyntacticValidationException(BadRequestException):
52
+ pass
53
+
54
+
55
+ class SemanticValidationException(BadRequestException):
56
+ pass
57
+
58
+
59
+ class SyntacticApiValidator:
60
+ def __init__(self) -> None:
61
+ with open(SCHEMA_FILE_PATH) as schemaFile:
62
+ self.schema: str = yaml.safe_load(schemaFile)
63
+
64
+ def validate(self, apiYaml: dict[str, object]) -> None:
65
+ try:
66
+ jsonschema.validate(instance=apiYaml, schema=self.schema)
67
+ except jsonschema.exceptions.ValidationError as validationError:
68
+ raise SyntacticValidationException(message=str(validationError))
69
+
70
+
71
+ def get_language_definition(languageCode: str) -> BaseLanguageDefinition:
72
+ if languageCode == 'python':
73
+ return PythonLanguageDefinition()
74
+ raise KibaException(f'Unrecognized language definition code: {languageCode}')
75
+
76
+
77
+ JINJA_FUNCTIONS: dict[str, typing.Callable[[str], BaseLanguageDefinition]] = {
78
+ 'get_language_definition': get_language_definition,
79
+ }
80
+
81
+
82
+ class Renderer:
83
+ def __init__(self) -> None:
84
+ self.templateDirectoryPath = str(TEMPLATES_DIRECTORY_PATH / 'python')
85
+ self.languageDefinition = get_language_definition(languageCode='python')
86
+ self.jinjaEnvironment = jinja2.Environment(loader=jinja2.FileSystemLoader(self.templateDirectoryPath), autoescape=jinja2.select_autoescape(), keep_trailing_newline=True, trim_blocks=True)
87
+ self.jinjaEnvironment.globals['autogenerated_notice'] = self.languageDefinition.AUTOGENERATED_NOTICE
88
+
89
+ @staticmethod
90
+ def _add_service_name_to_path(targetPath: str, outputDirectoryPath: str, serviceName: str, apiVersion: str) -> str:
91
+ replacedPath = targetPath
92
+ if targetPath is not outputDirectoryPath:
93
+ target = targetPath.split(outputDirectoryPath, 1)[1].strip('/')
94
+ replacedPath = os.path.join(outputDirectoryPath, target.format(service=serviceName, version=apiVersion))
95
+ return replacedPath
96
+
97
+ @staticmethod
98
+ def _transition_to_renderable_method(transition: BaseTransition) -> RenderableMethod:
99
+ defaultMethods = {'DELETE': 'DELETE', 'UPDATE': 'PATCH', 'GET': 'GET', 'CREATE': 'POST', 'LIST': 'GET'}
100
+ if transition.method is not None and transition.action.upper() in defaultMethods:
101
+ raise SemanticValidationException(message=f'Only custom transitions can set a method: {transition.action}')
102
+ method = transition.method or defaultMethods.get(transition.action.upper(), 'POST')
103
+ return RenderableMethod(method=RenderableString.from_upper_case_string(string=method))
104
+
105
+ def _action_operation(self, transition: BaseTransition, endpoint: RenderableEndpoint, knownTypeNames: set[str], defaultAuthentication: str | None, isIndirectAction: bool = False) -> RenderableOperation:
106
+ method = self._transition_to_renderable_method(transition=transition)
107
+ fields = [self._request_field_to_renderable_parameter(requestField=requestField, knownTypeNames=knownTypeNames) for requestField in transition.requestFields]
108
+ isQuery = method.method.upperCaseString == 'GET'
109
+ return RenderableOperation(
110
+ method=method,
111
+ endpoint=endpoint,
112
+ query=fields if isQuery else None,
113
+ body=None if isQuery else fields,
114
+ responseFields=[self._response_field_to_renderable_field(responseField=transition.responseField, knownTypeNames=knownTypeNames)] if transition.responseField is not None else None,
115
+ authentication=transition.authentication or defaultAuthentication,
116
+ isIndirectAction=isIndirectAction,
117
+ responseType=transition.responseType,
118
+ )
119
+
120
+ def _type_to_renderable_type(
121
+ self,
122
+ rawType: str,
123
+ isNullable: bool,
124
+ isList: bool,
125
+ knownTypeNames: set[str],
126
+ listValueTypes: list[str] | None = None,
127
+ mappingKeyType: str | None = None,
128
+ mappingValueTypes: list[str] | None = None,
129
+ ) -> RenderableType:
130
+ listValueTypes = listValueTypes or []
131
+ mappingValueTypes = mappingValueTypes or []
132
+ if rawType == ComplexTypes.MAPPING:
133
+ if mappingKeyType is None or len(mappingValueTypes) == 0:
134
+ raise KibaException(message='Mapping types require mappingKeyType and mappingValueTypes')
135
+ keyType = self._type_to_renderable_type(rawType=mappingKeyType, isNullable=False, isList=False, knownTypeNames=knownTypeNames)
136
+ nonNullValueTypes = [valueType for valueType in mappingValueTypes if valueType != 'Null']
137
+ if len(nonNullValueTypes) != 1 or len(nonNullValueTypes) - len(mappingValueTypes) not in (0, -1):
138
+ raise KibaException(message=f'Mapping types currently support one value type with an optional Null: {mappingValueTypes}')
139
+ valueType = self._type_to_renderable_type(rawType=nonNullValueTypes[0], isNullable='Null' in mappingValueTypes, isList=False, knownTypeNames=knownTypeNames)
140
+ return MappingRenderableType(isNullable=isNullable, isList=isList, keyType=keyType, valueType=valueType)
141
+ if rawType == 'List':
142
+ if isList or len(listValueTypes) != 1:
143
+ raise KibaException(message='List types require exactly one listValueTypes entry')
144
+ itemType = self._type_to_renderable_type(rawType=listValueTypes[0], isNullable=False, isList=False, knownTypeNames=knownTypeNames)
145
+ if itemType.isList:
146
+ raise KibaException(message='Nested list types are not supported')
147
+ return RenderableType(rawType=itemType.rawType, isNullable=isNullable, isList=True, className=itemType.className)
148
+ if listValueTypes:
149
+ raise KibaException(message=f'Only List types may define listValueTypes: {rawType}')
150
+ if rawType in PrimitiveTypes.ALL:
151
+ return RenderableType(rawType=rawType, isNullable=isNullable, isList=isList)
152
+ if rawType in knownTypeNames:
153
+ return RenderableType(rawType='class', isNullable=isNullable, className=RenderableString.from_pascal_case_string(string=rawType), isList=isList)
154
+ raise KibaException(message=f'Unknown type: {rawType}')
155
+
156
+ def _request_field_to_renderable_parameter(self, requestField: RequestField, knownTypeNames: set[str]) -> RenderableParameter:
157
+ return RenderableParameter(
158
+ name=RenderableString.from_kebab_case_string(string=requestField.name),
159
+ fieldType=self._type_to_renderable_type(
160
+ rawType=requestField.fieldType,
161
+ isNullable=not requestField.isRequired,
162
+ isList=False,
163
+ knownTypeNames=knownTypeNames,
164
+ listValueTypes=requestField.listValueTypes,
165
+ mappingKeyType=requestField.mappingKeyType,
166
+ mappingValueTypes=requestField.mappingValueTypes,
167
+ ),
168
+ defaultValue=requestField.defaultValue,
169
+ maxValue=requestField.maxValue,
170
+ minValue=requestField.minValue,
171
+ isRequired=requestField.isRequired,
172
+ isDeprecated=requestField.isDeprecated,
173
+ source=requestField.source,
174
+ )
175
+
176
+ def _response_field_to_renderable_field(self, responseField: ResponseField, knownTypeNames: set[str]) -> RenderableField:
177
+ return RenderableField(
178
+ name=RenderableString.from_kebab_case_string(string=responseField.name),
179
+ fieldType=self._type_to_renderable_type(
180
+ rawType=responseField.fieldType,
181
+ isNullable=responseField.isNullable,
182
+ isList=responseField.isList,
183
+ knownTypeNames=knownTypeNames,
184
+ listValueTypes=responseField.listValueTypes,
185
+ mappingKeyType=responseField.mappingKeyType,
186
+ mappingValueTypes=responseField.mappingValueTypes,
187
+ ),
188
+ )
189
+
190
+ def _attribute_to_renderable_attribute(self, attribute: Attribute, knownTypeNames: set[str], isNullable: bool | None = None) -> RenderableAttribute:
191
+ return RenderableAttribute(
192
+ name=RenderableString.from_kebab_case_string(string=attribute.name),
193
+ fieldType=self._type_to_renderable_type(
194
+ rawType=attribute.fieldType,
195
+ isNullable=attribute.isNullable if isNullable is None else isNullable,
196
+ isList=False,
197
+ knownTypeNames=knownTypeNames,
198
+ listValueTypes=attribute.listValueTypes,
199
+ mappingKeyType=attribute.mappingKeyType,
200
+ mappingValueTypes=attribute.mappingValueTypes,
201
+ ),
202
+ defaultValue=attribute.defaultValue,
203
+ isUnique=attribute.isUnique,
204
+ isPrimary=attribute.isPrimary,
205
+ isUpdatable=attribute.isUpdatable,
206
+ isAccessibleIndependently=attribute.isAccessibleIndependently,
207
+ isSettable=attribute.isSettable,
208
+ isNullable=attribute.isNullable,
209
+ )
210
+
211
+ def _attribute_to_renderable_parameter(self, attribute: Attribute, isRequired: bool, knownTypeNames: set[str], shouldUseDefault: bool = True) -> RenderableParameter:
212
+ return RenderableParameter(
213
+ name=RenderableString.from_kebab_case_string(string=attribute.name),
214
+ fieldType=self._type_to_renderable_type(
215
+ rawType=attribute.fieldType, isNullable=attribute.isNullable, isList=False, knownTypeNames=knownTypeNames, listValueTypes=attribute.listValueTypes, mappingKeyType=attribute.mappingKeyType, mappingValueTypes=attribute.mappingValueTypes
216
+ ),
217
+ defaultValue=attribute.defaultValue if shouldUseDefault else None,
218
+ isRequired=isRequired,
219
+ isDeprecated=attribute.isDeprecated,
220
+ )
221
+
222
+ @staticmethod
223
+ def _resource_full_name(resource: Resource, name: str) -> str:
224
+ return f'{resource.parentResource or ""}{name}'
225
+
226
+ def _resource_to_renderable_resource(self, resource: Resource, knownTypeNames: set[str]) -> RenderableResource:
227
+ attributes = [self._attribute_to_renderable_attribute(attribute=attribute, knownTypeNames=knownTypeNames) for attribute in resource.attributes]
228
+ return RenderableResource(
229
+ singleName=RenderableString.from_pascal_case_string(string=self._resource_full_name(resource=resource, name=resource.singleName)),
230
+ collectionName=RenderableString.from_pascal_case_string(string=self._resource_full_name(resource=resource, name=resource.collectionName)),
231
+ parentResource=resource.parentResource,
232
+ attributes=[attribute for attribute in attributes if not attribute.isAccessibleIndependently],
233
+ independentAttributes=[attribute for attribute in attributes if attribute.isAccessibleIndependently],
234
+ primaryAttribute=next((attribute for attribute in attributes if attribute.isPrimary), None),
235
+ accessibleFrom=resource.accessibleFrom,
236
+ )
237
+
238
+ def _get_processable_resource(self, renderableResource: RenderableResource, allResources: list[RenderableResource], knownTypeNames: set[str]) -> ProcessableResource:
239
+ parent: ProcessableResource.Parent | None = None
240
+ attributeOccurrences: list[ProcessableResource.IndependentAttributeOccurrence] = []
241
+ parentResource = next(iter([otherResource for otherResource in allResources if renderableResource.parentResource and otherResource.singleName.pascalCaseString == renderableResource.parentResource]), None)
242
+ if parentResource is not None:
243
+ parentProcessableResource = self._get_processable_resource(renderableResource=parentResource, allResources=allResources, knownTypeNames=knownTypeNames)
244
+ isOneToOne = renderableResource.primaryAttribute == parentResource.primaryAttribute
245
+ parent = ProcessableResource.Parent(processableResource=parentProcessableResource, isOneToOne=isOneToOne)
246
+ for otherResource in allResources:
247
+ for attribute in otherResource.independentAttributes:
248
+ if attribute.fieldType.rawType == 'class' and attribute.fieldType.className == renderableResource.singleName:
249
+ occursInResource = self._get_processable_resource(renderableResource=otherResource, allResources=allResources, knownTypeNames=knownTypeNames)
250
+ occursInAttribute = attribute
251
+ attributeOccurrences.append(ProcessableResource.IndependentAttributeOccurrence(processableResource=occursInResource, attribute=occursInAttribute))
252
+ return ProcessableResource(renderableResource=renderableResource, parent=parent, occursAsIndependentAttribute=attributeOccurrences)
253
+
254
+ @staticmethod
255
+ def _relative_name(name: RenderableString, anchor: RenderableResource | None) -> RenderableString:
256
+ return RenderableString(string=name.string[len(anchor.singleName.string) :]) if anchor is not None else name
257
+
258
+ def _resource_component(self, resource: RenderableResource, attribute: RenderableAttribute | None, anchor: RenderableResource | None) -> ResourceEndpointComponent:
259
+ return ResourceEndpointComponent(
260
+ resource=resource,
261
+ attribute=attribute,
262
+ relativeSingleName=self._relative_name(name=resource.singleName, anchor=anchor),
263
+ relativeCollectionName=self._relative_name(name=resource.collectionName, anchor=anchor),
264
+ )
265
+
266
+ def _get_anchored_endpoints(self, resource: RenderableResource, anchor: ProcessableResource | None) -> EndpointContainer:
267
+ anchorEndpoints = self._get_canonical_endpoints(processableResource=anchor).individualEndpoints if anchor is not None else [RenderableEndpoint(components=[])]
268
+ anchorResource = anchor.renderableResource if anchor is not None else None
269
+ individualEndpoints = [RenderableEndpoint(components=[*endpoint.components, self._resource_component(resource=resource, attribute=resource.primaryAttribute, anchor=anchorResource)]) for endpoint in anchorEndpoints] if resource.primaryAttribute else []
270
+ collectionEndpoints = [RenderableEndpoint(components=[*endpoint.components, self._resource_component(resource=resource, attribute=None, anchor=anchorResource)]) for endpoint in anchorEndpoints]
271
+ return EndpointContainer(individualEndpoints=individualEndpoints, collectionEndpoints=collectionEndpoints)
272
+
273
+ def _get_canonical_endpoints(self, processableResource: ProcessableResource) -> EndpointContainer:
274
+ resource = processableResource.renderableResource
275
+ parent = processableResource.parent
276
+ if parent is None:
277
+ return self._get_anchored_endpoints(resource=resource, anchor=None)
278
+ if parent.isOneToOne:
279
+ parentEndpoints = self._get_canonical_endpoints(processableResource=parent.processableResource).individualEndpoints
280
+ relativeSingleName = self._relative_name(name=resource.singleName, anchor=parent.processableResource.renderableResource)
281
+ return EndpointContainer(
282
+ individualEndpoints=[RenderableEndpoint(components=[*endpoint.components, SingleResourceEndpointComponent(resource=resource, relativeSingleName=relativeSingleName)]) for endpoint in parentEndpoints],
283
+ collectionEndpoints=[],
284
+ )
285
+ return self._get_anchored_endpoints(resource=resource, anchor=parent.processableResource)
286
+
287
+ def _get_resource_endpoints(self, processableResource: ProcessableResource) -> EndpointContainer:
288
+ resource = processableResource.renderableResource
289
+ individualEndpoints: list[RenderableEndpoint] = []
290
+ collectionEndpoints: list[RenderableEndpoint] = []
291
+ for anchorName in resource.accessibleFrom:
292
+ anchor = None
293
+ ancestor = processableResource.parent
294
+ while anchorName != 'root' and ancestor is not None and anchor is None:
295
+ if ancestor.processableResource.renderableResource.singleName.pascalCaseString == anchorName:
296
+ anchor = ancestor.processableResource
297
+ ancestor = ancestor.processableResource.parent
298
+ anchoredEndpoints = self._get_anchored_endpoints(resource=resource, anchor=anchor)
299
+ individualEndpoints += anchoredEndpoints.individualEndpoints
300
+ collectionEndpoints += anchoredEndpoints.collectionEndpoints
301
+ canonicalEndpoints = self._get_canonical_endpoints(processableResource=processableResource)
302
+ individualEndpoints += canonicalEndpoints.individualEndpoints
303
+ collectionEndpoints += canonicalEndpoints.collectionEndpoints
304
+ for occurrence in processableResource.occursAsIndependentAttribute:
305
+ parentEndpoints = self._get_canonical_endpoints(processableResource=occurrence.processableResource).individualEndpoints
306
+ individualEndpoints += [RenderableEndpoint(components=[*endpoint.components, AttributeEndpointComponent(attribute=occurrence.attribute, resource=resource)]) for endpoint in parentEndpoints]
307
+ return EndpointContainer(individualEndpoints=individualEndpoints, collectionEndpoints=collectionEndpoints)
308
+
309
+ @staticmethod
310
+ def _is_string_mapping(renderableType: RenderableType) -> bool:
311
+ return isinstance(renderableType, MappingRenderableType) and renderableType.keyType.rawType == PrimitiveTypes.STRING and renderableType.valueType.rawType == PrimitiveTypes.STRING
312
+
313
+ def _operation_errors(self, operation: RenderableOperation) -> list[str]:
314
+ lastComponent = operation.endpoint.components[-1] if operation.endpoint.components else None
315
+ name = lastComponent.actionName.kebabCaseString if isinstance(lastComponent, ActionEndpointComponent) and lastComponent.actionName is not None else operation.method.method.upperCaseString
316
+ parametersBySource: dict[str, list[RenderableParameter]] = collections.defaultdict(list)
317
+ for parameter in operation.parameters:
318
+ parametersBySource[parameter.source].append(parameter)
319
+ errors: list[str] = []
320
+ errors += [f'{name}: query-parameters field {parameter.name.kebabCaseString} must be a Mapping of String to String' for parameter in parametersBySource[FieldSources.QUERY_PARAMETERS] if not self._is_string_mapping(renderableType=parameter.fieldType)]
321
+ if len(parametersBySource[FieldSources.QUERY_PARAMETERS]) > 1:
322
+ errors.append(f'{name}: only one query-parameters field is allowed')
323
+ if operation.responseType == ResponseTypes.STREAM and not operation.responseFields:
324
+ errors.append(f'{name}: stream transitions need a responseField')
325
+ if operation.responseType == ResponseTypes.REDIRECT and operation.responseFields:
326
+ errors.append(f'{name}: redirect transitions cannot have a responseField')
327
+ return errors
328
+
329
+ def build_renderable_api(self, api: KibaApi) -> RenderableApi:
330
+ fullResourceNames = [self._resource_full_name(resource=resource, name=resource.singleName) for resource in api.resources]
331
+ knownTypeNames = set(fullResourceNames)
332
+ resourceErrors = [f'Duplicate resource name: {name}' for name, count in collections.Counter(fullResourceNames).items() if count > 1]
333
+ resourceErrors += [f'Unknown parent resource {resource.parentResource} for {resource.singleName}' for resource in api.resources if resource.parentResource is not None and resource.parentResource not in knownTypeNames]
334
+ resourcesByName = dict(zip(fullResourceNames, api.resources, strict=False))
335
+ for resource, fullName in zip(api.resources, fullResourceNames, strict=True):
336
+ ancestorNames: list[str] = []
337
+ ancestor = resourcesByName.get(resource.parentResource) if resource.parentResource is not None else None
338
+ while ancestor is not None:
339
+ ancestorName = self._resource_full_name(resource=ancestor, name=ancestor.singleName)
340
+ ancestorNames.append(ancestorName)
341
+ ancestor = resourcesByName.get(ancestor.parentResource) if ancestor.parentResource is not None else None
342
+ for anchorName in resource.accessibleFrom:
343
+ if anchorName == resource.parentResource or (anchorName == 'root' and resource.parentResource is None):
344
+ resourceErrors.append(f'{fullName} is already accessible from {anchorName}')
345
+ elif anchorName != 'root' and anchorName not in ancestorNames:
346
+ resourceErrors.append(f'{fullName} can only be accessible from root or an ancestor, not {anchorName}')
347
+ parentPrimaryAttribute = resourcesByName[resource.parentResource].primaryAttribute if resource.parentResource in resourcesByName else None
348
+ isOneToOne = parentPrimaryAttribute is not None and resource.primaryAttribute is not None and resource.primaryAttribute.name == parentPrimaryAttribute.name
349
+ expectedPrimaryName = f'{RenderableString.from_pascal_case_string(string=fullName).kebabCaseString}-id'
350
+ if resource.transitions and resource.primaryAttribute is not None and not isOneToOne and resource.primaryAttribute.name != expectedPrimaryName:
351
+ resourceErrors.append(f'Primary attribute of {fullName} must be {expectedPrimaryName}, found {resource.primaryAttribute.name}')
352
+ if resourceErrors:
353
+ raise SemanticValidationException(message='\n'.join(resourceErrors))
354
+ renderableOperations = [
355
+ self._action_operation(
356
+ transition=transition,
357
+ endpoint=RenderableEndpoint(components=[ActionEndpointComponent(actionName=RenderableString.from_separated_string(string=transition.action, separator='-'))]),
358
+ knownTypeNames=knownTypeNames,
359
+ defaultAuthentication=api.defaultAuthentication,
360
+ )
361
+ for transition in api.transitions
362
+ ]
363
+ renderableExceptions = [
364
+ RenderableException(
365
+ name=RenderableString.from_pascal_case_string(string=exception.name),
366
+ message=exception.message,
367
+ statusCode=exception.statusCode,
368
+ fields=[
369
+ ExceptionRenderableField(
370
+ name=RenderableString.from_snake_case_string(string=field.name),
371
+ fieldType=self._type_to_renderable_type(rawType=field.fieldType, isNullable=field.isNullable, isList=False, knownTypeNames=knownTypeNames, listValueTypes=field.listValueTypes),
372
+ defaultValue=field.defaultValue,
373
+ isRequired=field.isRequired,
374
+ )
375
+ for field in exception.fields
376
+ ],
377
+ )
378
+ for exception in api.exceptions
379
+ ]
380
+ renderableApi = RenderableApi(
381
+ version=api.versionObject,
382
+ serviceName=RenderableString.from_kebab_case_string(string=api.serviceName),
383
+ pythonClientPackageName=api.pythonClientPackageName,
384
+ typescriptClientPackageName=api.typescriptClientPackageName,
385
+ resources=[],
386
+ operations=renderableOperations,
387
+ exceptions=renderableExceptions,
388
+ )
389
+ renderableResources: list[RenderableResource] = [self._resource_to_renderable_resource(resource=resource, knownTypeNames=knownTypeNames) for resource in api.resources]
390
+ uncoveredTransitions: list[str] = []
391
+ for resource, renderableResource in zip(api.resources, renderableResources, strict=True):
392
+ processableResource = self._get_processable_resource(renderableResource=renderableResource, allResources=renderableResources, knownTypeNames=knownTypeNames)
393
+ endpoints = self._get_resource_endpoints(processableResource=processableResource)
394
+ for transition in resource.transitions:
395
+ operationCount = len(renderableApi.operations)
396
+ method: RenderableMethod = self._transition_to_renderable_method(transition=transition)
397
+ if transition.isIndirectAction and transition.action.upper() in {'DELETE', 'CREATE', 'UPDATE', 'GET', 'LIST'}:
398
+ raise SemanticValidationException(message=f'Only custom transitions can be indirect actions: {transition.action}')
399
+ if transition.responseType != ResponseTypes.JSON and transition.action.upper() in {'DELETE', 'CREATE', 'UPDATE', 'GET', 'LIST'}:
400
+ raise SemanticValidationException(message=f'Only custom transitions can set a responseType: {transition.action}')
401
+ if transition.action.upper() == 'DELETE':
402
+ for endpoint in endpoints.individualEndpoints:
403
+ renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=None, responseFields=[], authentication=transition.authentication or api.defaultAuthentication))
404
+ elif transition.action.upper() == 'CREATE':
405
+ parentResource = processableResource.parent.processableResource if processableResource.parent is not None else None
406
+ isOneToOne = (processableResource.renderableResource.primaryAttribute == parentResource.renderableResource.primaryAttribute) if parentResource is not None else False
407
+ body = [
408
+ self._attribute_to_renderable_parameter(attribute=attribute, isRequired=(not attribute.isNullable and attribute.defaultValue is None), knownTypeNames=knownTypeNames)
409
+ for attribute in resource.attributes
410
+ if attribute.isSettable or (attribute.isPrimary and isOneToOne)
411
+ ]
412
+ body += [self._request_field_to_renderable_parameter(requestField=requestField, knownTypeNames=knownTypeNames) for requestField in transition.requestFields]
413
+ returnValue = RenderableField(name=processableResource.renderableResource.singleName, fieldType=RenderableType(rawType='class', className=processableResource.renderableResource.singleName, isNullable=False, isList=False))
414
+ for endpoint in endpoints.collectionEndpoints:
415
+ renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=body or None, responseFields=[returnValue], authentication=transition.authentication or api.defaultAuthentication))
416
+ elif transition.action.upper() == 'UPDATE':
417
+ returnValue = RenderableField(name=processableResource.renderableResource.singleName, fieldType=RenderableType(rawType='class', className=processableResource.renderableResource.singleName, isNullable=False, isList=False))
418
+ body = [self._attribute_to_renderable_parameter(attribute=attribute, isRequired=False, knownTypeNames=knownTypeNames, shouldUseDefault=False) for attribute in resource.attributes if attribute.isUpdatable]
419
+ body += [self._request_field_to_renderable_parameter(requestField=requestField, knownTypeNames=knownTypeNames) for requestField in transition.requestFields]
420
+ for endpoint in endpoints.individualEndpoints:
421
+ renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=body or None, responseFields=[returnValue], authentication=transition.authentication or api.defaultAuthentication))
422
+ elif transition.action.upper() == 'GET':
423
+ returnValue = RenderableField(name=processableResource.renderableResource.singleName, fieldType=RenderableType(rawType='class', className=processableResource.renderableResource.singleName, isNullable=False, isList=False))
424
+ for endpoint in endpoints.individualEndpoints:
425
+ renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=None, responseFields=[returnValue], authentication=transition.authentication or api.defaultAuthentication))
426
+ endpointsAccessibleWithUnique = [endpoint for endpoint in endpoints.collectionEndpoints if isinstance(endpoint.components[-1], ResourceEndpointComponent)]
427
+ uniqueAttributes = [attribute for attribute in resource.attributes if attribute.isUnique and not attribute.isPrimary and not attribute.shouldDisableGetEndpoint and not attribute.isAccessibleIndependently]
428
+ for endpoint in endpointsAccessibleWithUnique:
429
+ lastComponent = typing.cast(ResourceEndpointComponent, endpoint.components[-1])
430
+ for attribute in uniqueAttributes:
431
+ getByUniqueEndpoint = RenderableEndpoint(
432
+ components=[
433
+ *endpoint.components[:-1],
434
+ ResourceEndpointComponent(
435
+ resource=lastComponent.resource,
436
+ attribute=self._attribute_to_renderable_attribute(attribute=attribute, isNullable=False, knownTypeNames=knownTypeNames),
437
+ relativeSingleName=lastComponent.relativeSingleName,
438
+ relativeCollectionName=lastComponent.relativeCollectionName,
439
+ ),
440
+ ]
441
+ )
442
+ renderableApi.operations.append(RenderableOperation(method=method, endpoint=getByUniqueEndpoint, query=None, body=None, responseFields=[returnValue], authentication=transition.authentication or api.defaultAuthentication))
443
+ elif transition.action.upper() == 'LIST':
444
+ returnValue = RenderableField(name=processableResource.renderableResource.collectionName, fieldType=RenderableType(rawType='class', className=processableResource.renderableResource.singleName, isNullable=False, isList=True))
445
+ query = [self._request_field_to_renderable_parameter(requestField=requestField, knownTypeNames=knownTypeNames) for requestField in transition.requestFields]
446
+ for endpoint in endpoints.collectionEndpoints:
447
+ renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=query or None, body=None, responseFields=[returnValue], authentication=transition.authentication or api.defaultAuthentication))
448
+ else: # Else it's a custom action
449
+ if transition.operatesOn == 'individual':
450
+ customEndpoints = endpoints.individualEndpoints
451
+ elif transition.operatesOn == 'collection':
452
+ customEndpoints = endpoints.collectionEndpoints
453
+ else:
454
+ raise KibaException(message=f'Resource {processableResource.renderableResource.singleName.pascalCaseString} has custom transition with invalid operatesOn field: {transition.operatesOn}')
455
+ for endpoint in customEndpoints:
456
+ customEndpoint = RenderableEndpoint(components=[*endpoint.components, ActionEndpointComponent(actionName=RenderableString.from_separated_string(string=transition.action, separator='-'))])
457
+ renderableApi.operations.append(self._action_operation(transition=transition, endpoint=customEndpoint, knownTypeNames=knownTypeNames, defaultAuthentication=api.defaultAuthentication, isIndirectAction=transition.isIndirectAction))
458
+ if len(renderableApi.operations) == operationCount:
459
+ uncoveredTransitions.append(f'{renderableResource.singleName.pascalCaseString} {transition.action}')
460
+ if uncoveredTransitions:
461
+ raise SemanticValidationException(
462
+ message=f'Transitions with no endpoint: {", ".join(uncoveredTransitions)}. Item transitions need a primary attribute, collection transitions need direct access or a parent resource, and CREATE is not supported on one-to-one child resources.'
463
+ )
464
+ operationErrors = [error for operation in renderableApi.operations for error in self._operation_errors(operation=operation)]
465
+ if operationErrors:
466
+ raise SemanticValidationException(message='\n'.join(operationErrors))
467
+ renderableApi.resources.extend(renderableResources)
468
+ return renderableApi
469
+
470
+ def render_directory(self, api: KibaApi, outputDirectoryPath: str) -> None:
471
+ renderableApi = self.build_renderable_api(api=api)
472
+ validationErrors = self.languageDefinition.validation_errors(api=renderableApi)
473
+ if validationErrors:
474
+ raise SemanticValidationException(message='\n'.join(validationErrors))
475
+ for path, _, files in os.walk(self.templateDirectoryPath, followlinks=True):
476
+ outputPathForTemplatePath = path.replace(self.templateDirectoryPath, outputDirectoryPath)
477
+ targetPath = self._add_service_name_to_path(targetPath=outputPathForTemplatePath, outputDirectoryPath=outputDirectoryPath, serviceName=self.languageDefinition.package_name(api=renderableApi), apiVersion=renderableApi.version.major)
478
+ templateRelativePath = path.replace(self.templateDirectoryPath, '')
479
+ logging.debug(msg=f'Creating directory: {targetPath}')
480
+ os.makedirs(os.path.join(targetPath), exist_ok=True)
481
+ for filename in files:
482
+ sourceFilePath = os.path.join(templateRelativePath, filename)
483
+ if filename.endswith('.j2'):
484
+ targetFilename = filename[: -len('.j2')]
485
+ targetFilePath = self._add_service_name_to_path(
486
+ targetPath=os.path.join(targetPath, targetFilename),
487
+ outputDirectoryPath=outputDirectoryPath,
488
+ serviceName=self.languageDefinition.package_name(api=renderableApi),
489
+ apiVersion=renderableApi.version.major,
490
+ )
491
+ logging.debug(f'Rendering {sourceFilePath} to: {targetFilePath}')
492
+ template = self.jinjaEnvironment.get_template(name=sourceFilePath)
493
+ template.globals.update(JINJA_FUNCTIONS)
494
+ with open(targetFilePath, 'w') as targetFile:
495
+ targetFile.write(template.render(api=renderableApi, target=self.languageDefinition))
496
+ else:
497
+ targetFilePath = self._add_service_name_to_path(
498
+ targetPath=os.path.join(targetPath, filename),
499
+ outputDirectoryPath=outputDirectoryPath,
500
+ serviceName=self.languageDefinition.package_name(api=renderableApi),
501
+ apiVersion=renderableApi.version.major,
502
+ )
503
+ logging.debug(f'Copying {sourceFilePath} to: {targetFilePath}')
504
+ with open(os.path.join(self.templateDirectoryPath, sourceFilePath.lstrip('/')), 'r') as sourceFile, open(targetFilePath, 'w') as targetFile:
505
+ targetFile.write(sourceFile.read())