geniespec 0.1.2.dev1__tar.gz → 0.1.2.dev5__tar.gz
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-0.1.2.dev1 → geniespec-0.1.2.dev5}/PKG-INFO +26 -1
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/README.md +25 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/pyproject.toml +1 -1
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/pyproject.toml.orig +1 -1
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/api_schema.yaml +45 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/genie.py +30 -10
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/languages.py +42 -4
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/renderable_types.py +11 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/v{version}/api.py.j2 +8 -1
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/v{version}/endpoints.py.j2 +3 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/v{version}/exceptions.py.j2 +3 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/v{version}/internal.py.j2 +3 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/v{version}/resources.py.j2 +3 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/types.py +19 -1
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/__init__.py +0 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/cli.py +0 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/__init__.py.j2 +0 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/v{version}/__init__.py.j2 +0 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/typescript-client/{service}/v{version}/client.ts.j2 +0 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/typescript-client/{service}/v{version}/endpoints.ts.j2 +0 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/typescript-client/{service}/v{version}/index.ts.j2 +0 -0
- {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/typescript-client/{service}/v{version}/resources.ts.j2 +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: geniespec
|
|
3
|
-
Version: 0.1.2.
|
|
3
|
+
Version: 0.1.2.dev5
|
|
4
4
|
Summary: Generate API code from an api.yaml spec
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Requires-Dist: jinja2>=3.1.6
|
|
@@ -68,6 +68,31 @@ resources:
|
|
|
68
68
|
|
|
69
69
|
Fields use it like any other type (`type: ViewContent`, also inside `List` and `Mapping`). `python-server` generates a `ViewContentV1` pydantic discriminated union in `resources`; `typescript-client` generates a `ViewContentV1` union type and a `viewContentV1FromObject` function that builds the member class named by the discriminator (and throws for unknown values).
|
|
70
70
|
|
|
71
|
+
`type: Uuid` is a string that must be a valid UUID. It works anywhere `String` does, including as a primary attribute in paths. `python-server` types it as `uuid.UUID`, so pydantic rejects other strings with a 400, internal methods get `uuid.UUID` objects and responses write them in the standard lowercase hyphenated form; `typescript-client` types it as `string`.
|
|
72
|
+
|
|
73
|
+
## Rate limits
|
|
74
|
+
|
|
75
|
+
Name rate limits at the top level, then pick one per transition with `rateLimit`. `defaultRateLimit` applies to every transition that doesn't set one, and `rateLimit: none` turns it off for a transition, the same way `defaultAuthentication` and `authentication` work:
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
rateLimits:
|
|
79
|
+
- name: standard-read
|
|
80
|
+
perMinute: 30
|
|
81
|
+
perHour: 300
|
|
82
|
+
- name: public-read
|
|
83
|
+
perMinute: 30
|
|
84
|
+
perHour: 300
|
|
85
|
+
keyBy: ip
|
|
86
|
+
defaultRateLimit: standard-read
|
|
87
|
+
transitions:
|
|
88
|
+
- action: GET-HEALTH
|
|
89
|
+
method: GET
|
|
90
|
+
authentication: none
|
|
91
|
+
rateLimit: public-read
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
A rate limit needs at least one of `perMinute`, `perFiveMinutes`, `perHour` and `perDay`. `keyBy` is `user` (the default) or `ip`. Requests over any window are rejected with a 429. Limits keyed by `user` count per authenticated user, so a transition using one needs `authentication`. `python-server` writes each rate limit as a `RATE_LIMIT_<NAME>` kiba-core `RateLimitConfig` and passes it to `route`; keying by `ip` needs kiba-core's `OriginIpMiddleware`. `typescript-client` ignores rate limits.
|
|
95
|
+
|
|
71
96
|
# Development
|
|
72
97
|
|
|
73
98
|
```
|
|
@@ -54,6 +54,31 @@ resources:
|
|
|
54
54
|
|
|
55
55
|
Fields use it like any other type (`type: ViewContent`, also inside `List` and `Mapping`). `python-server` generates a `ViewContentV1` pydantic discriminated union in `resources`; `typescript-client` generates a `ViewContentV1` union type and a `viewContentV1FromObject` function that builds the member class named by the discriminator (and throws for unknown values).
|
|
56
56
|
|
|
57
|
+
`type: Uuid` is a string that must be a valid UUID. It works anywhere `String` does, including as a primary attribute in paths. `python-server` types it as `uuid.UUID`, so pydantic rejects other strings with a 400, internal methods get `uuid.UUID` objects and responses write them in the standard lowercase hyphenated form; `typescript-client` types it as `string`.
|
|
58
|
+
|
|
59
|
+
## Rate limits
|
|
60
|
+
|
|
61
|
+
Name rate limits at the top level, then pick one per transition with `rateLimit`. `defaultRateLimit` applies to every transition that doesn't set one, and `rateLimit: none` turns it off for a transition, the same way `defaultAuthentication` and `authentication` work:
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
rateLimits:
|
|
65
|
+
- name: standard-read
|
|
66
|
+
perMinute: 30
|
|
67
|
+
perHour: 300
|
|
68
|
+
- name: public-read
|
|
69
|
+
perMinute: 30
|
|
70
|
+
perHour: 300
|
|
71
|
+
keyBy: ip
|
|
72
|
+
defaultRateLimit: standard-read
|
|
73
|
+
transitions:
|
|
74
|
+
- action: GET-HEALTH
|
|
75
|
+
method: GET
|
|
76
|
+
authentication: none
|
|
77
|
+
rateLimit: public-read
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
A rate limit needs at least one of `perMinute`, `perFiveMinutes`, `perHour` and `perDay`. `keyBy` is `user` (the default) or `ip`. Requests over any window are rejected with a 429. Limits keyed by `user` count per authenticated user, so a transition using one needs `authentication`. `python-server` writes each rate limit as a `RATE_LIMIT_<NAME>` kiba-core `RateLimitConfig` and passes it to `route`; keying by `ip` needs kiba-core's `OriginIpMiddleware`. `typescript-client` ignores rate limits.
|
|
81
|
+
|
|
57
82
|
# Development
|
|
58
83
|
|
|
59
84
|
```
|
|
@@ -45,11 +45,46 @@ properties:
|
|
|
45
45
|
items:
|
|
46
46
|
type: string
|
|
47
47
|
defaultAuthentication:
|
|
48
|
+
type:
|
|
49
|
+
- "null"
|
|
50
|
+
- string
|
|
51
|
+
pattern: '^[a-z][a-z0-9_-]*$'
|
|
52
|
+
rateLimits:
|
|
53
|
+
type: array
|
|
54
|
+
items:
|
|
55
|
+
$ref: '#/definitions/RateLimit'
|
|
56
|
+
defaultRateLimit:
|
|
48
57
|
type:
|
|
49
58
|
- "null"
|
|
50
59
|
- string
|
|
51
60
|
pattern: '^[a-z][a-z0-9-]*$'
|
|
52
61
|
definitions:
|
|
62
|
+
RateLimit:
|
|
63
|
+
type: object
|
|
64
|
+
additionalProperties: false
|
|
65
|
+
required:
|
|
66
|
+
- name
|
|
67
|
+
properties:
|
|
68
|
+
name:
|
|
69
|
+
type: string
|
|
70
|
+
pattern: '^[a-z][a-z0-9-]*$'
|
|
71
|
+
perMinute:
|
|
72
|
+
type: integer
|
|
73
|
+
minimum: 1
|
|
74
|
+
perFiveMinutes:
|
|
75
|
+
type: integer
|
|
76
|
+
minimum: 1
|
|
77
|
+
perHour:
|
|
78
|
+
type: integer
|
|
79
|
+
minimum: 1
|
|
80
|
+
perDay:
|
|
81
|
+
type: integer
|
|
82
|
+
minimum: 1
|
|
83
|
+
keyBy:
|
|
84
|
+
type: string
|
|
85
|
+
enum:
|
|
86
|
+
- user
|
|
87
|
+
- ip
|
|
53
88
|
EnumValues:
|
|
54
89
|
type: array
|
|
55
90
|
minItems: 1
|
|
@@ -192,6 +227,11 @@ definitions:
|
|
|
192
227
|
responseField:
|
|
193
228
|
$ref: '#/definitions/ResponseField'
|
|
194
229
|
authentication:
|
|
230
|
+
type:
|
|
231
|
+
- "null"
|
|
232
|
+
- string
|
|
233
|
+
pattern: '^[a-z][a-z0-9_-]*$'
|
|
234
|
+
rateLimit:
|
|
195
235
|
type:
|
|
196
236
|
- "null"
|
|
197
237
|
- string
|
|
@@ -223,6 +263,11 @@ definitions:
|
|
|
223
263
|
responseField:
|
|
224
264
|
$ref: '#/definitions/ResponseField'
|
|
225
265
|
authentication:
|
|
266
|
+
type:
|
|
267
|
+
- "null"
|
|
268
|
+
- string
|
|
269
|
+
pattern: '^[a-z][a-z0-9_-]*$'
|
|
270
|
+
rateLimit:
|
|
226
271
|
type:
|
|
227
272
|
- "null"
|
|
228
273
|
- string
|
|
@@ -32,6 +32,7 @@ from geniespec.renderable_types import RenderableOneOf
|
|
|
32
32
|
from geniespec.renderable_types import RenderableOneOfMember
|
|
33
33
|
from geniespec.renderable_types import RenderableOperation
|
|
34
34
|
from geniespec.renderable_types import RenderableParameter
|
|
35
|
+
from geniespec.renderable_types import RenderableRateLimit
|
|
35
36
|
from geniespec.renderable_types import RenderableRawTypes
|
|
36
37
|
from geniespec.renderable_types import RenderableResource
|
|
37
38
|
from geniespec.renderable_types import RenderableString
|
|
@@ -46,6 +47,7 @@ from geniespec.types import ComplexTypes
|
|
|
46
47
|
from geniespec.types import FieldSources
|
|
47
48
|
from geniespec.types import KibaApi
|
|
48
49
|
from geniespec.types import PrimitiveTypes
|
|
50
|
+
from geniespec.types import RateLimitKeys
|
|
49
51
|
from geniespec.types import RequestField
|
|
50
52
|
from geniespec.types import Resource
|
|
51
53
|
from geniespec.types import ResponseField
|
|
@@ -114,7 +116,7 @@ class Renderer:
|
|
|
114
116
|
method = transition.method or defaultMethods.get(transition.action.upper(), 'POST')
|
|
115
117
|
return RenderableMethod(method=RenderableString.from_upper_case_string(string=method))
|
|
116
118
|
|
|
117
|
-
def _action_operation(self, transition: BaseTransition, endpoint: RenderableEndpoint, knownTypeNames: dict[str, str], defaultAuthentication: str | None, isIndirectAction: bool = False) -> RenderableOperation:
|
|
119
|
+
def _action_operation(self, transition: BaseTransition, endpoint: RenderableEndpoint, knownTypeNames: dict[str, str], defaultAuthentication: str | None, defaultRateLimit: str | None, isIndirectAction: bool = False) -> RenderableOperation:
|
|
118
120
|
method = self._transition_to_renderable_method(transition=transition)
|
|
119
121
|
fields = [self._request_field_to_renderable_parameter(requestField=requestField, knownTypeNames=knownTypeNames) for requestField in transition.requestFields]
|
|
120
122
|
isQuery = method.method.upperCaseString == 'GET'
|
|
@@ -125,6 +127,7 @@ class Renderer:
|
|
|
125
127
|
body=None if isQuery else fields,
|
|
126
128
|
responseFields=[self._response_field_to_renderable_field(responseField=transition.responseField, knownTypeNames=knownTypeNames)] if transition.responseField is not None else None,
|
|
127
129
|
authentication=transition.authentication or defaultAuthentication,
|
|
130
|
+
rateLimit=transition.rateLimit or defaultRateLimit,
|
|
128
131
|
isIndirectAction=isIndirectAction,
|
|
129
132
|
responseType=transition.responseType,
|
|
130
133
|
)
|
|
@@ -344,7 +347,7 @@ class Renderer:
|
|
|
344
347
|
def _is_string_mapping(renderableType: RenderableType) -> bool:
|
|
345
348
|
return isinstance(renderableType, MappingRenderableType) and renderableType.keyType.rawType == PrimitiveTypes.STRING and renderableType.valueType.rawType == PrimitiveTypes.STRING
|
|
346
349
|
|
|
347
|
-
def _operation_errors(self, operation: RenderableOperation) -> list[str]:
|
|
350
|
+
def _operation_errors(self, operation: RenderableOperation, rateLimitsByName: dict[str, RenderableRateLimit]) -> list[str]:
|
|
348
351
|
lastComponent = operation.endpoint.components[-1] if operation.endpoint.components else None
|
|
349
352
|
name = lastComponent.actionName.kebabCaseString if isinstance(lastComponent, ActionEndpointComponent) and lastComponent.actionName is not None else operation.method.method.upperCaseString
|
|
350
353
|
parametersBySource: dict[str, list[RenderableParameter]] = collections.defaultdict(list)
|
|
@@ -369,6 +372,12 @@ class Renderer:
|
|
|
369
372
|
for parameter in operation.parameters
|
|
370
373
|
if parameter.source == FieldSources.REQUEST and (parameter.fieldType.isList or isinstance(parameter.fieldType, MappingRenderableType) or parameter.fieldType.rawType not in FORM_FIELD_RAW_TYPES)
|
|
371
374
|
]
|
|
375
|
+
if operation.rateLimit not in (None, 'none'):
|
|
376
|
+
rateLimit = rateLimitsByName.get(operation.rateLimit)
|
|
377
|
+
if rateLimit is None:
|
|
378
|
+
errors.append(f'{name}: unknown rate limit {operation.rateLimit}')
|
|
379
|
+
elif rateLimit.keyBy == RateLimitKeys.USER and operation.authentication in (None, 'none'):
|
|
380
|
+
errors.append(f'{name}: rate limit {operation.rateLimit} is keyed by user, so the transition needs authentication')
|
|
372
381
|
return errors
|
|
373
382
|
|
|
374
383
|
@staticmethod
|
|
@@ -414,6 +423,9 @@ class Renderer:
|
|
|
414
423
|
}
|
|
415
424
|
resourceErrors = [f'Duplicate type name: {name}' for name, count in collections.Counter(fullResourceNames + enumNames + oneOfNames).items() if count > 1]
|
|
416
425
|
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 fullResourceNames]
|
|
426
|
+
resourceErrors += [f'Duplicate rate limit: {name}' for name, count in collections.Counter(rateLimit.name for rateLimit in api.rateLimits).items() if count > 1]
|
|
427
|
+
resourceErrors += ['Rate limit none is reserved for transitions without a rate limit' for rateLimit in api.rateLimits if rateLimit.name == 'none']
|
|
428
|
+
resourceErrors += [f'Rate limit {rateLimit.name} needs at least one of perMinute, perFiveMinutes, perHour or perDay' for rateLimit in api.rateLimits if all(window is None for window in (rateLimit.perMinute, rateLimit.perFiveMinutes, rateLimit.perHour, rateLimit.perDay))]
|
|
417
429
|
resourcesByName = dict(zip(fullResourceNames, api.resources, strict=False))
|
|
418
430
|
renderableOneOfs: list[RenderableOneOf] = []
|
|
419
431
|
for oneOf in api.oneOfs:
|
|
@@ -445,6 +457,7 @@ class Renderer:
|
|
|
445
457
|
endpoint=RenderableEndpoint(components=[ActionEndpointComponent(actionName=RenderableString.from_separated_string(string=transition.action, separator='-'))]),
|
|
446
458
|
knownTypeNames=knownTypeNames,
|
|
447
459
|
defaultAuthentication=api.defaultAuthentication,
|
|
460
|
+
defaultRateLimit=api.defaultRateLimit,
|
|
448
461
|
)
|
|
449
462
|
for transition in api.transitions
|
|
450
463
|
]
|
|
@@ -475,6 +488,10 @@ class Renderer:
|
|
|
475
488
|
exceptions=renderableExceptions,
|
|
476
489
|
enums=[RenderableEnum(name=RenderableString.from_pascal_case_string(string=enum.name), values=enum.values) for enum in api.enums],
|
|
477
490
|
oneOfs=renderableOneOfs,
|
|
491
|
+
rateLimits=[
|
|
492
|
+
RenderableRateLimit(name=RenderableString.from_kebab_case_string(string=rateLimit.name), perMinute=rateLimit.perMinute, perFiveMinutes=rateLimit.perFiveMinutes, perHour=rateLimit.perHour, perDay=rateLimit.perDay, keyBy=rateLimit.keyBy)
|
|
493
|
+
for rateLimit in api.rateLimits
|
|
494
|
+
],
|
|
478
495
|
)
|
|
479
496
|
renderableResources: list[RenderableResource] = [self._resource_to_renderable_resource(resource=resource, knownTypeNames=knownTypeNames) for resource in api.resources]
|
|
480
497
|
uncoveredTransitions: list[str] = []
|
|
@@ -482,6 +499,8 @@ class Renderer:
|
|
|
482
499
|
processableResource = self._get_processable_resource(renderableResource=renderableResource, allResources=renderableResources, knownTypeNames=knownTypeNames)
|
|
483
500
|
endpoints = self._get_resource_endpoints(processableResource=processableResource)
|
|
484
501
|
for transition in resource.transitions:
|
|
502
|
+
authentication = transition.authentication or api.defaultAuthentication
|
|
503
|
+
rateLimit = transition.rateLimit or api.defaultRateLimit
|
|
485
504
|
operationCount = len(renderableApi.operations)
|
|
486
505
|
method: RenderableMethod = self._transition_to_renderable_method(transition=transition)
|
|
487
506
|
if transition.isIndirectAction and transition.action.upper() in {'DELETE', 'CREATE', 'UPDATE', 'GET', 'LIST'}:
|
|
@@ -490,7 +509,7 @@ class Renderer:
|
|
|
490
509
|
raise SemanticValidationException(message=f'Only custom transitions can set a responseType: {transition.action}')
|
|
491
510
|
if transition.action.upper() == 'DELETE':
|
|
492
511
|
for endpoint in endpoints.individualEndpoints:
|
|
493
|
-
renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=None, responseFields=[], authentication=
|
|
512
|
+
renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=None, responseFields=[], authentication=authentication, rateLimit=rateLimit))
|
|
494
513
|
elif transition.action.upper() == 'CREATE':
|
|
495
514
|
parentResource = processableResource.parent.processableResource if processableResource.parent is not None else None
|
|
496
515
|
isOneToOne = (processableResource.renderableResource.primaryAttribute == parentResource.renderableResource.primaryAttribute) if parentResource is not None else False
|
|
@@ -502,17 +521,17 @@ class Renderer:
|
|
|
502
521
|
body += [self._request_field_to_renderable_parameter(requestField=requestField, knownTypeNames=knownTypeNames) for requestField in transition.requestFields]
|
|
503
522
|
returnValue = RenderableField(name=processableResource.renderableResource.singleName, fieldType=RenderableType(rawType='class', className=processableResource.renderableResource.singleName, isNullable=False, isList=False))
|
|
504
523
|
for endpoint in endpoints.collectionEndpoints:
|
|
505
|
-
renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=body or None, responseFields=[returnValue], authentication=
|
|
524
|
+
renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=body or None, responseFields=[returnValue], authentication=authentication, rateLimit=rateLimit))
|
|
506
525
|
elif transition.action.upper() == 'UPDATE':
|
|
507
526
|
returnValue = RenderableField(name=processableResource.renderableResource.singleName, fieldType=RenderableType(rawType='class', className=processableResource.renderableResource.singleName, isNullable=False, isList=False))
|
|
508
527
|
body = [self._attribute_to_renderable_parameter(attribute=attribute, isRequired=False, knownTypeNames=knownTypeNames, shouldUseDefault=False) for attribute in resource.attributes if attribute.isUpdatable]
|
|
509
528
|
body += [self._request_field_to_renderable_parameter(requestField=requestField, knownTypeNames=knownTypeNames) for requestField in transition.requestFields]
|
|
510
529
|
for endpoint in endpoints.individualEndpoints:
|
|
511
|
-
renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=body or None, responseFields=[returnValue], authentication=
|
|
530
|
+
renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=body or None, responseFields=[returnValue], authentication=authentication, rateLimit=rateLimit))
|
|
512
531
|
elif transition.action.upper() == 'GET':
|
|
513
532
|
returnValue = RenderableField(name=processableResource.renderableResource.singleName, fieldType=RenderableType(rawType='class', className=processableResource.renderableResource.singleName, isNullable=False, isList=False))
|
|
514
533
|
for endpoint in endpoints.individualEndpoints:
|
|
515
|
-
renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=None, responseFields=[returnValue], authentication=
|
|
534
|
+
renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=None, responseFields=[returnValue], authentication=authentication, rateLimit=rateLimit))
|
|
516
535
|
endpointsAccessibleWithUnique = [endpoint for endpoint in endpoints.collectionEndpoints if isinstance(endpoint.components[-1], ResourceEndpointComponent)]
|
|
517
536
|
uniqueAttributes = [attribute for attribute in resource.attributes if attribute.isUnique and not attribute.isPrimary and not attribute.shouldDisableGetEndpoint and not attribute.isAccessibleIndependently]
|
|
518
537
|
for endpoint in endpointsAccessibleWithUnique:
|
|
@@ -529,12 +548,12 @@ class Renderer:
|
|
|
529
548
|
),
|
|
530
549
|
]
|
|
531
550
|
)
|
|
532
|
-
renderableApi.operations.append(RenderableOperation(method=method, endpoint=getByUniqueEndpoint, query=None, body=None, responseFields=[returnValue], authentication=
|
|
551
|
+
renderableApi.operations.append(RenderableOperation(method=method, endpoint=getByUniqueEndpoint, query=None, body=None, responseFields=[returnValue], authentication=authentication, rateLimit=rateLimit))
|
|
533
552
|
elif transition.action.upper() == 'LIST':
|
|
534
553
|
returnValue = RenderableField(name=processableResource.renderableResource.collectionName, fieldType=RenderableType(rawType='class', className=processableResource.renderableResource.singleName, isNullable=False, isList=True))
|
|
535
554
|
query = [self._request_field_to_renderable_parameter(requestField=requestField, knownTypeNames=knownTypeNames) for requestField in transition.requestFields]
|
|
536
555
|
for endpoint in endpoints.collectionEndpoints:
|
|
537
|
-
renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=query or None, body=None, responseFields=[returnValue], authentication=
|
|
556
|
+
renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=query or None, body=None, responseFields=[returnValue], authentication=authentication, rateLimit=rateLimit))
|
|
538
557
|
else: # Else it's a custom action
|
|
539
558
|
if transition.operatesOn == 'individual':
|
|
540
559
|
customEndpoints = endpoints.individualEndpoints
|
|
@@ -544,14 +563,15 @@ class Renderer:
|
|
|
544
563
|
raise KibaException(message=f'Resource {processableResource.renderableResource.singleName.pascalCaseString} has custom transition with invalid operatesOn field: {transition.operatesOn}')
|
|
545
564
|
for endpoint in customEndpoints:
|
|
546
565
|
customEndpoint = RenderableEndpoint(components=[*endpoint.components, ActionEndpointComponent(actionName=RenderableString.from_separated_string(string=transition.action, separator='-'))])
|
|
547
|
-
renderableApi.operations.append(self._action_operation(transition=transition, endpoint=customEndpoint, knownTypeNames=knownTypeNames, defaultAuthentication=api.defaultAuthentication, isIndirectAction=transition.isIndirectAction))
|
|
566
|
+
renderableApi.operations.append(self._action_operation(transition=transition, endpoint=customEndpoint, knownTypeNames=knownTypeNames, defaultAuthentication=api.defaultAuthentication, defaultRateLimit=api.defaultRateLimit, isIndirectAction=transition.isIndirectAction))
|
|
548
567
|
if len(renderableApi.operations) == operationCount:
|
|
549
568
|
uncoveredTransitions.append(f'{renderableResource.singleName.pascalCaseString} {transition.action}')
|
|
550
569
|
if uncoveredTransitions:
|
|
551
570
|
raise SemanticValidationException(
|
|
552
571
|
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.'
|
|
553
572
|
)
|
|
554
|
-
|
|
573
|
+
rateLimitsByName = {rateLimit.name.kebabCaseString: rateLimit for rateLimit in renderableApi.rateLimits}
|
|
574
|
+
operationErrors = [error for operation in renderableApi.operations for error in self._operation_errors(operation=operation, rateLimitsByName=rateLimitsByName)]
|
|
555
575
|
operationErrors += self._file_errors(resources=renderableResources, exceptions=renderableExceptions)
|
|
556
576
|
if operationErrors:
|
|
557
577
|
raise SemanticValidationException(message='\n'.join(operationErrors))
|
|
@@ -15,6 +15,7 @@ from geniespec.renderable_types import RenderableField
|
|
|
15
15
|
from geniespec.renderable_types import RenderableOneOf
|
|
16
16
|
from geniespec.renderable_types import RenderableOperation
|
|
17
17
|
from geniespec.renderable_types import RenderableParameter
|
|
18
|
+
from geniespec.renderable_types import RenderableRateLimit
|
|
18
19
|
from geniespec.renderable_types import RenderableRawTypes
|
|
19
20
|
from geniespec.renderable_types import RenderableResource
|
|
20
21
|
from geniespec.renderable_types import RenderableString
|
|
@@ -72,6 +73,9 @@ class BaseLanguageDefinition:
|
|
|
72
73
|
def string_type(self, isNullable: bool) -> str:
|
|
73
74
|
raise NotImplementedError
|
|
74
75
|
|
|
76
|
+
def uuid_type(self, isNullable: bool) -> str:
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
75
79
|
def enum_type(self, isNullable: bool, renderableType: RenderableType, apiVersion: Version, isLocalReference: bool) -> str:
|
|
76
80
|
raise NotImplementedError
|
|
77
81
|
|
|
@@ -109,6 +113,8 @@ class BaseLanguageDefinition:
|
|
|
109
113
|
return self.double_type(isNullable=isNullable)
|
|
110
114
|
if yamlType == PrimitiveTypes.STRING:
|
|
111
115
|
return self.string_type(isNullable=isNullable)
|
|
116
|
+
if yamlType == PrimitiveTypes.UUID:
|
|
117
|
+
return self.uuid_type(isNullable=isNullable)
|
|
112
118
|
if yamlType == PrimitiveTypes.BOOLEAN:
|
|
113
119
|
return self.boolean_type(isNullable=isNullable)
|
|
114
120
|
if yamlType == PrimitiveTypes.DATETIME:
|
|
@@ -226,9 +232,9 @@ class BaseLanguageDefinition:
|
|
|
226
232
|
return output
|
|
227
233
|
if component.attribute.isUnique and not component.attribute.isPrimary:
|
|
228
234
|
output.append(self.kebab_name(name=component.attribute.name))
|
|
229
|
-
if component.attribute.fieldType.rawType not in (PrimitiveTypes.INTEGER, PrimitiveTypes.LONG_INTEGER, PrimitiveTypes.STRING):
|
|
230
|
-
raise KibaException(message=f'ResourceEndpointComponent created with an invalid attribute type of {component.attribute.fieldType}. Valid types are: integer, long integer and
|
|
231
|
-
output.append(self.path_parameter_segment(parameterName=self.identifier(name=component.attribute.name), isInteger=component.attribute.fieldType.rawType
|
|
235
|
+
if component.attribute.fieldType.rawType not in (PrimitiveTypes.INTEGER, PrimitiveTypes.LONG_INTEGER, PrimitiveTypes.STRING, PrimitiveTypes.UUID):
|
|
236
|
+
raise KibaException(message=f'ResourceEndpointComponent created with an invalid attribute type of {component.attribute.fieldType}. Valid types are: integer, long integer, string and uuid.')
|
|
237
|
+
output.append(self.path_parameter_segment(parameterName=self.identifier(name=component.attribute.name), isInteger=component.attribute.fieldType.rawType in (PrimitiveTypes.INTEGER, PrimitiveTypes.LONG_INTEGER)))
|
|
232
238
|
return output
|
|
233
239
|
raise KibaException(message=f'Unsupported endpoint component: {type(component)}')
|
|
234
240
|
|
|
@@ -342,6 +348,9 @@ class PythonLanguageDefinition(BaseLanguageDefinition):
|
|
|
342
348
|
def string_type(self, isNullable: bool) -> str:
|
|
343
349
|
return 'str | None' if isNullable else 'str'
|
|
344
350
|
|
|
351
|
+
def uuid_type(self, isNullable: bool) -> str:
|
|
352
|
+
return 'uuid.UUID | None' if isNullable else 'uuid.UUID'
|
|
353
|
+
|
|
345
354
|
def enum_type(self, isNullable: bool, renderableType: RenderableType, apiVersion: Version, isLocalReference: bool) -> str:
|
|
346
355
|
if renderableType.className is None:
|
|
347
356
|
typeName = self.enum_literal(values=renderableType.enumValues)
|
|
@@ -370,6 +379,20 @@ class PythonLanguageDefinition(BaseLanguageDefinition):
|
|
|
370
379
|
def requires_auth_resolver(cls, api: RenderableApi) -> bool:
|
|
371
380
|
return any(cls.route_auth(operation=operation) is not None for operation in api.operations)
|
|
372
381
|
|
|
382
|
+
@staticmethod
|
|
383
|
+
def route_rate_limit(operation: RenderableOperation) -> str | None:
|
|
384
|
+
return operation.rateLimit if operation.rateLimit not in (None, 'none') else None
|
|
385
|
+
|
|
386
|
+
@staticmethod
|
|
387
|
+
def rate_limit_constant_name(name: str) -> str:
|
|
388
|
+
return f'RATE_LIMIT_{RenderableString.from_kebab_case_string(string=name).snakeCaseString.upper()}'
|
|
389
|
+
|
|
390
|
+
@staticmethod
|
|
391
|
+
def rate_limit_config(rateLimit: RenderableRateLimit) -> str:
|
|
392
|
+
windows = {'perMinute': rateLimit.perMinute, 'perFiveMinutes': rateLimit.perFiveMinutes, 'perHour': rateLimit.perHour, 'perDay': rateLimit.perDay}
|
|
393
|
+
entries = [f"'{window}': {limit}" for window, limit in windows.items() if limit is not None]
|
|
394
|
+
return '{' + ', '.join([*entries, f"'keyBy': '{rateLimit.keyBy}'"]) + '}'
|
|
395
|
+
|
|
373
396
|
def operation_name(self, operation: RenderableOperation) -> str:
|
|
374
397
|
return self.snake_name(name=self._operation_name(operation=operation))
|
|
375
398
|
|
|
@@ -408,6 +431,18 @@ class PythonLanguageDefinition(BaseLanguageDefinition):
|
|
|
408
431
|
return PythonLanguageDefinition._contains_json(renderableType=renderableType.keyType) or PythonLanguageDefinition._contains_json(renderableType=renderableType.valueType)
|
|
409
432
|
return renderableType.rawType == PrimitiveTypes.JSON
|
|
410
433
|
|
|
434
|
+
@staticmethod
|
|
435
|
+
def _contains_uuid(renderableType: RenderableType) -> bool:
|
|
436
|
+
if isinstance(renderableType, MappingRenderableType):
|
|
437
|
+
return PythonLanguageDefinition._contains_uuid(renderableType=renderableType.keyType) or PythonLanguageDefinition._contains_uuid(renderableType=renderableType.valueType)
|
|
438
|
+
return renderableType.rawType == PrimitiveTypes.UUID
|
|
439
|
+
|
|
440
|
+
def requires_uuid(self, api: RenderableApi) -> bool:
|
|
441
|
+
fieldTypes = [attribute.fieldType for resource in api.resources for attribute in [*resource.attributes, *resource.independentAttributes]]
|
|
442
|
+
fieldTypes += [parameter.fieldType for operation in api.operations for parameter in operation.parameters] + [field.fieldType for operation in api.operations for field in operation.responseFields or []]
|
|
443
|
+
fieldTypes += [field.fieldType for exception in api.exceptions for field in exception.fields]
|
|
444
|
+
return any(self._contains_uuid(renderableType=fieldType) for fieldType in fieldTypes)
|
|
445
|
+
|
|
411
446
|
def resources_require_json(self, api: RenderableApi) -> bool:
|
|
412
447
|
return any(self._contains_json(renderableType=attribute.fieldType) for resource in api.resources for attribute in resource.attributes)
|
|
413
448
|
|
|
@@ -547,6 +582,9 @@ class TypeScriptLanguageDefinition(BaseLanguageDefinition):
|
|
|
547
582
|
def string_type(self, isNullable: bool) -> str:
|
|
548
583
|
return self._nullable(typeName='string', isNullable=isNullable)
|
|
549
584
|
|
|
585
|
+
def uuid_type(self, isNullable: bool) -> str:
|
|
586
|
+
return self._nullable(typeName='string', isNullable=isNullable)
|
|
587
|
+
|
|
550
588
|
def enum_type(self, isNullable: bool, renderableType: RenderableType, apiVersion: Version, isLocalReference: bool) -> str:
|
|
551
589
|
if renderableType.className is None:
|
|
552
590
|
return self._nullable(typeName=f'({self.enum_union(values=renderableType.enumValues)})', isNullable=isNullable)
|
|
@@ -665,7 +703,7 @@ class TypeScriptLanguageDefinition(BaseLanguageDefinition):
|
|
|
665
703
|
return f'{parserName if isLocalReference else f"Resources.{parserName}"}({value} as RawObject)'
|
|
666
704
|
if renderableType.rawType == 'class' and renderableType.className is not None:
|
|
667
705
|
return f'{self.type(renderableType=renderableType, apiVersion=apiVersion, isLocalReference=isLocalReference)}.fromObject({value} as RawObject)'
|
|
668
|
-
if renderableType.rawType
|
|
706
|
+
if renderableType.rawType in (PrimitiveTypes.STRING, PrimitiveTypes.UUID):
|
|
669
707
|
return f'String({value})'
|
|
670
708
|
if renderableType.rawType in (PrimitiveTypes.INTEGER, PrimitiveTypes.LONG_INTEGER, PrimitiveTypes.FLOAT, PrimitiveTypes.DOUBLE):
|
|
671
709
|
return f'Number({value})'
|
|
@@ -212,6 +212,7 @@ class RenderableOperation(BaseModel):
|
|
|
212
212
|
body: list[RenderableParameter] | None
|
|
213
213
|
responseFields: list[RenderableField] | None
|
|
214
214
|
authentication: str | None
|
|
215
|
+
rateLimit: str | None = None
|
|
215
216
|
isIndirectAction: bool = False
|
|
216
217
|
responseType: str = ResponseTypes.JSON
|
|
217
218
|
|
|
@@ -241,6 +242,15 @@ class RenderableException(BaseModel):
|
|
|
241
242
|
fields: list[ExceptionRenderableField]
|
|
242
243
|
|
|
243
244
|
|
|
245
|
+
class RenderableRateLimit(BaseModel):
|
|
246
|
+
name: RenderableString
|
|
247
|
+
perMinute: int | None
|
|
248
|
+
perFiveMinutes: int | None
|
|
249
|
+
perHour: int | None
|
|
250
|
+
perDay: int | None
|
|
251
|
+
keyBy: str
|
|
252
|
+
|
|
253
|
+
|
|
244
254
|
class RenderableApi(BaseModel):
|
|
245
255
|
version: Version
|
|
246
256
|
serviceName: RenderableString
|
|
@@ -251,6 +261,7 @@ class RenderableApi(BaseModel):
|
|
|
251
261
|
exceptions: list[RenderableException]
|
|
252
262
|
enums: list[RenderableEnum] = []
|
|
253
263
|
oneOfs: list[RenderableOneOf] = []
|
|
264
|
+
rateLimits: list[RenderableRateLimit] = []
|
|
254
265
|
|
|
255
266
|
|
|
256
267
|
class ProcessableResource(BaseModel):
|
|
@@ -8,6 +8,9 @@ import datetime
|
|
|
8
8
|
from core.api.api_request import KibaApiRequest
|
|
9
9
|
from core.api.route import route
|
|
10
10
|
from core.api.route_auth import RouteAuthResolver
|
|
11
|
+
{% if api.rateLimits %}
|
|
12
|
+
from core.api.route_metadata import RateLimitConfig
|
|
13
|
+
{% endif %}
|
|
11
14
|
{% if target.requires_redirect(api=api) %}
|
|
12
15
|
from core.exceptions import FoundRedirectException
|
|
13
16
|
{% endif %}
|
|
@@ -16,14 +19,18 @@ from starlette.routing import BaseRoute
|
|
|
16
19
|
from starlette.routing import Route
|
|
17
20
|
from . import endpoints
|
|
18
21
|
from .internal import {{ target.internal_class_name(api=api) }}
|
|
22
|
+
{% for rateLimit in api.rateLimits %}
|
|
23
|
+
{{ target.rate_limit_constant_name(name=rateLimit.name.kebabCaseString) }}: RateLimitConfig = {{ target.rate_limit_config(rateLimit=rateLimit) }}
|
|
24
|
+
{% endfor %}
|
|
19
25
|
def create_routes(internal: {{ target.internal_class_name(api=api) }}, {% if target.requires_auth_resolver(api=api) %}authResolver: RouteAuthResolver{% else %}authResolver: RouteAuthResolver | None = None{% endif %}) -> list[BaseRoute]:
|
|
20
26
|
|
|
21
27
|
|
|
22
28
|
{% for operation in api.operations %}
|
|
23
29
|
{% set auth = target.route_auth(operation=operation) %}
|
|
30
|
+
{% set rate_limit = target.route_rate_limit(operation=operation) %}
|
|
24
31
|
{% set class_name = target.operation_class_name(operation=operation) %}
|
|
25
32
|
{% set result_name = target.identifier(name=operation.responseFields[0].name) if operation.responseFields else 'result' %}
|
|
26
|
-
@route(requestType=endpoints.{{ class_name }}Request, responseType=endpoints.{{ class_name }}Response{% if target.is_streaming(operation=operation) %}, isStreaming=True{% endif %}{% if auth is not none %}, authResolver=authResolver, auth='{{ auth }}'{% endif %})
|
|
33
|
+
@route(requestType=endpoints.{{ class_name }}Request, responseType=endpoints.{{ class_name }}Response{% if target.is_streaming(operation=operation) %}, isStreaming=True{% endif %}{% if auth is not none %}, authResolver=authResolver, auth='{{ auth }}'{% endif %}{% if rate_limit is not none %}, rateLimit={{ target.rate_limit_constant_name(name=rate_limit) }}{% endif %})
|
|
27
34
|
async def {{ target.operation_name(operation=operation) }}(request: KibaApiRequest[endpoints.{{ class_name }}Request]) -> {% if target.is_streaming(operation=operation) %}collections.abc.AsyncIterator[endpoints.{{ class_name }}Response]{% else %}endpoints.{{ class_name }}Response{% endif %}:
|
|
28
35
|
{% for parameter in target.request_parameters(operation=operation) %}
|
|
29
36
|
{% set param_name = target.identifier(name=parameter.name) %}
|
|
@@ -4,6 +4,9 @@ import datetime
|
|
|
4
4
|
{% if target.requires_optional_field_default(api=api) or target.operations_require_typing(api=api) %}
|
|
5
5
|
import typing
|
|
6
6
|
{% endif %}
|
|
7
|
+
{% if target.requires_uuid(api=api) %}
|
|
8
|
+
import uuid
|
|
9
|
+
{% endif %}
|
|
7
10
|
|
|
8
11
|
{% if target.operations_require_json(api=api) %}
|
|
9
12
|
import pydantic
|
|
@@ -12,11 +12,12 @@ class PrimitiveTypes:
|
|
|
12
12
|
FLOAT = 'Float'
|
|
13
13
|
DOUBLE = 'Double'
|
|
14
14
|
STRING = 'String'
|
|
15
|
+
UUID = 'Uuid'
|
|
15
16
|
BOOLEAN = 'Boolean'
|
|
16
17
|
DATETIME = 'Datetime'
|
|
17
18
|
DATE = 'Date'
|
|
18
19
|
JSON = 'Json'
|
|
19
|
-
ALL: ClassVar[list[str]] = [LONG_INTEGER, INTEGER, FLOAT, DOUBLE, STRING, BOOLEAN, DATETIME, DATE, JSON]
|
|
20
|
+
ALL: ClassVar[list[str]] = [LONG_INTEGER, INTEGER, FLOAT, DOUBLE, STRING, UUID, BOOLEAN, DATETIME, DATE, JSON]
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class FieldSources:
|
|
@@ -30,6 +31,20 @@ class ResponseTypes:
|
|
|
30
31
|
REDIRECT = 'redirect'
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
class RateLimitKeys:
|
|
35
|
+
USER = 'user'
|
|
36
|
+
IP = 'ip'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ApiRateLimit(BaseModel):
|
|
40
|
+
name: str
|
|
41
|
+
perMinute: int | None = None
|
|
42
|
+
perFiveMinutes: int | None = None
|
|
43
|
+
perHour: int | None = None
|
|
44
|
+
perDay: int | None = None
|
|
45
|
+
keyBy: str = RateLimitKeys.USER
|
|
46
|
+
|
|
47
|
+
|
|
33
48
|
class ComplexTypes:
|
|
34
49
|
MAPPING = 'Mapping'
|
|
35
50
|
ALL: ClassVar[list[str]] = [MAPPING]
|
|
@@ -104,6 +119,7 @@ class BaseTransition(BaseModel):
|
|
|
104
119
|
responseField: ResponseField | None = None
|
|
105
120
|
requestFields: list[RequestField] = []
|
|
106
121
|
authentication: str | None = None
|
|
122
|
+
rateLimit: str | None = None
|
|
107
123
|
responseType: str = ResponseTypes.JSON
|
|
108
124
|
|
|
109
125
|
|
|
@@ -163,6 +179,8 @@ class KibaApi(BaseModel):
|
|
|
163
179
|
enums: list[ApiEnum] = []
|
|
164
180
|
oneOfs: list[ApiOneOf] = []
|
|
165
181
|
defaultAuthentication: str | None = None
|
|
182
|
+
rateLimits: list[ApiRateLimit] = []
|
|
183
|
+
defaultRateLimit: str | None = None
|
|
166
184
|
|
|
167
185
|
@property
|
|
168
186
|
def versionObject(self) -> Version: # noqa: N802
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|