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/__init__.py +0 -0
- geniespec/api_schema.yaml +318 -0
- geniespec/cli.py +85 -0
- geniespec/genie.py +505 -0
- geniespec/languages.py +408 -0
- geniespec/renderable_types.py +246 -0
- geniespec/templates/python/pyproject.toml.j2 +8 -0
- geniespec/templates/python/src/{service}/__init__.py.j2 +1 -0
- geniespec/templates/python/src/{service}/py.typed +0 -0
- geniespec/templates/python/src/{service}/v{version}/__init__.py.j2 +1 -0
- geniespec/templates/python/src/{service}/v{version}/api.py.j2 +68 -0
- geniespec/templates/python/src/{service}/v{version}/endpoints.py.j2 +38 -0
- geniespec/templates/python/src/{service}/v{version}/exceptions.py.j2 +68 -0
- geniespec/templates/python/src/{service}/v{version}/internal.py.j2 +30 -0
- geniespec/templates/python/src/{service}/v{version}/resources.py.j2 +22 -0
- geniespec/types.py +147 -0
- geniespec-0.1.1.dev2.dist-info/METADATA +77 -0
- geniespec-0.1.1.dev2.dist-info/RECORD +20 -0
- geniespec-0.1.1.dev2.dist-info/WHEEL +4 -0
- geniespec-0.1.1.dev2.dist-info/entry_points.txt +3 -0
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())
|