geniespec 0.1.1.dev8__tar.gz → 0.1.2.dev4__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.1.dev8 → geniespec-0.1.2.dev4}/PKG-INFO +27 -2
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/README.md +26 -1
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/pyproject.toml +1 -1
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/pyproject.toml.orig +1 -1
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/api_schema.yaml +45 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/genie.py +30 -10
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/languages.py +15 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/renderable_types.py +11 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/v{version}/api.py.j2 +8 -1
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/types.py +17 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/__init__.py +0 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/cli.py +0 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/__init__.py.j2 +0 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/v{version}/__init__.py.j2 +0 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/v{version}/endpoints.py.j2 +0 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/v{version}/exceptions.py.j2 +0 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/v{version}/internal.py.j2 +0 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/v{version}/resources.py.j2 +0 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/typescript-client/{service}/v{version}/client.ts.j2 +0 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/typescript-client/{service}/v{version}/endpoints.ts.j2 +0 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/typescript-client/{service}/v{version}/index.ts.j2 +0 -0
- {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/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.
|
|
3
|
+
Version: 0.1.2.dev4
|
|
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,29 @@ 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
|
+
## Rate limits
|
|
72
|
+
|
|
73
|
+
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:
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
rateLimits:
|
|
77
|
+
- name: standard-read
|
|
78
|
+
perMinute: 30
|
|
79
|
+
perHour: 300
|
|
80
|
+
- name: public-read
|
|
81
|
+
perMinute: 30
|
|
82
|
+
perHour: 300
|
|
83
|
+
keyBy: ip
|
|
84
|
+
defaultRateLimit: standard-read
|
|
85
|
+
transitions:
|
|
86
|
+
- action: GET-HEALTH
|
|
87
|
+
method: GET
|
|
88
|
+
authentication: none
|
|
89
|
+
rateLimit: public-read
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
71
94
|
# Development
|
|
72
95
|
|
|
73
96
|
```
|
|
@@ -82,7 +105,9 @@ make install-typescript-client
|
|
|
82
105
|
make test-typescript-client
|
|
83
106
|
```
|
|
84
107
|
|
|
85
|
-
Merges to `main` publish a dev version to PyPI. To release, bump `version` in `pyproject.toml` in a PR, then run the Release workflow on `main` (Actions → Release → Run workflow). It tags `vX.Y.Z` and the Publish workflow publishes it. Only the release workflow can push version tags.
|
|
108
|
+
Merges to `main` publish a dev version to PyPI, except for changes that only touch the landing page. To release, bump `version` in `pyproject.toml` in a PR, then run the Release workflow on `main` (Actions → Release → Run workflow). It tags `vX.Y.Z` and the Publish workflow publishes it. Only the release workflow can push version tags.
|
|
109
|
+
|
|
110
|
+
`landing/` is the static site for geniespec.com (plain HTML, CSS and JS, no build step). Preview it with `python3 -m http.server --directory landing`. Merges that touch it sync it to the kiba sites bucket and invalidate CloudFront (`.github/workflows/landing-deploy.yml`).
|
|
86
111
|
|
|
87
112
|
# Things that need work
|
|
88
113
|
|
|
@@ -54,6 +54,29 @@ 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
|
+
## Rate limits
|
|
58
|
+
|
|
59
|
+
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:
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
rateLimits:
|
|
63
|
+
- name: standard-read
|
|
64
|
+
perMinute: 30
|
|
65
|
+
perHour: 300
|
|
66
|
+
- name: public-read
|
|
67
|
+
perMinute: 30
|
|
68
|
+
perHour: 300
|
|
69
|
+
keyBy: ip
|
|
70
|
+
defaultRateLimit: standard-read
|
|
71
|
+
transitions:
|
|
72
|
+
- action: GET-HEALTH
|
|
73
|
+
method: GET
|
|
74
|
+
authentication: none
|
|
75
|
+
rateLimit: public-read
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
57
80
|
# Development
|
|
58
81
|
|
|
59
82
|
```
|
|
@@ -68,7 +91,9 @@ make install-typescript-client
|
|
|
68
91
|
make test-typescript-client
|
|
69
92
|
```
|
|
70
93
|
|
|
71
|
-
Merges to `main` publish a dev version to PyPI. To release, bump `version` in `pyproject.toml` in a PR, then run the Release workflow on `main` (Actions → Release → Run workflow). It tags `vX.Y.Z` and the Publish workflow publishes it. Only the release workflow can push version tags.
|
|
94
|
+
Merges to `main` publish a dev version to PyPI, except for changes that only touch the landing page. To release, bump `version` in `pyproject.toml` in a PR, then run the Release workflow on `main` (Actions → Release → Run workflow). It tags `vX.Y.Z` and the Publish workflow publishes it. Only the release workflow can push version tags.
|
|
95
|
+
|
|
96
|
+
`landing/` is the static site for geniespec.com (plain HTML, CSS and JS, no build step). Preview it with `python3 -m http.server --directory landing`. Merges that touch it sync it to the kiba sites bucket and invalidate CloudFront (`.github/workflows/landing-deploy.yml`).
|
|
72
97
|
|
|
73
98
|
# Things that need work
|
|
74
99
|
|
|
@@ -49,7 +49,42 @@ properties:
|
|
|
49
49
|
- "null"
|
|
50
50
|
- string
|
|
51
51
|
pattern: '^[a-z][a-z0-9-]*$'
|
|
52
|
+
rateLimits:
|
|
53
|
+
type: array
|
|
54
|
+
items:
|
|
55
|
+
$ref: '#/definitions/RateLimit'
|
|
56
|
+
defaultRateLimit:
|
|
57
|
+
type:
|
|
58
|
+
- "null"
|
|
59
|
+
- string
|
|
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
|
|
@@ -196,6 +231,11 @@ definitions:
|
|
|
196
231
|
- "null"
|
|
197
232
|
- string
|
|
198
233
|
pattern: '^[a-z][a-z0-9-]*$'
|
|
234
|
+
rateLimit:
|
|
235
|
+
type:
|
|
236
|
+
- "null"
|
|
237
|
+
- string
|
|
238
|
+
pattern: '^[a-z][a-z0-9-]*$'
|
|
199
239
|
responseType:
|
|
200
240
|
type: string
|
|
201
241
|
enum:
|
|
@@ -227,6 +267,11 @@ definitions:
|
|
|
227
267
|
- "null"
|
|
228
268
|
- string
|
|
229
269
|
pattern: '^[a-z][a-z0-9-]*$'
|
|
270
|
+
rateLimit:
|
|
271
|
+
type:
|
|
272
|
+
- "null"
|
|
273
|
+
- string
|
|
274
|
+
pattern: '^[a-z][a-z0-9-]*$'
|
|
230
275
|
responseType:
|
|
231
276
|
type: string
|
|
232
277
|
enum:
|
|
@@ -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
|
|
@@ -370,6 +371,20 @@ class PythonLanguageDefinition(BaseLanguageDefinition):
|
|
|
370
371
|
def requires_auth_resolver(cls, api: RenderableApi) -> bool:
|
|
371
372
|
return any(cls.route_auth(operation=operation) is not None for operation in api.operations)
|
|
372
373
|
|
|
374
|
+
@staticmethod
|
|
375
|
+
def route_rate_limit(operation: RenderableOperation) -> str | None:
|
|
376
|
+
return operation.rateLimit if operation.rateLimit not in (None, 'none') else None
|
|
377
|
+
|
|
378
|
+
@staticmethod
|
|
379
|
+
def rate_limit_constant_name(name: str) -> str:
|
|
380
|
+
return f'RATE_LIMIT_{RenderableString.from_kebab_case_string(string=name).snakeCaseString.upper()}'
|
|
381
|
+
|
|
382
|
+
@staticmethod
|
|
383
|
+
def rate_limit_config(rateLimit: RenderableRateLimit) -> str:
|
|
384
|
+
windows = {'perMinute': rateLimit.perMinute, 'perFiveMinutes': rateLimit.perFiveMinutes, 'perHour': rateLimit.perHour, 'perDay': rateLimit.perDay}
|
|
385
|
+
entries = [f"'{window}': {limit}" for window, limit in windows.items() if limit is not None]
|
|
386
|
+
return '{' + ', '.join([*entries, f"'keyBy': '{rateLimit.keyBy}'"]) + '}'
|
|
387
|
+
|
|
373
388
|
def operation_name(self, operation: RenderableOperation) -> str:
|
|
374
389
|
return self.snake_name(name=self._operation_name(operation=operation))
|
|
375
390
|
|
|
@@ -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) %}
|
|
@@ -30,6 +30,20 @@ class ResponseTypes:
|
|
|
30
30
|
REDIRECT = 'redirect'
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
class RateLimitKeys:
|
|
34
|
+
USER = 'user'
|
|
35
|
+
IP = 'ip'
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ApiRateLimit(BaseModel):
|
|
39
|
+
name: str
|
|
40
|
+
perMinute: int | None = None
|
|
41
|
+
perFiveMinutes: int | None = None
|
|
42
|
+
perHour: int | None = None
|
|
43
|
+
perDay: int | None = None
|
|
44
|
+
keyBy: str = RateLimitKeys.USER
|
|
45
|
+
|
|
46
|
+
|
|
33
47
|
class ComplexTypes:
|
|
34
48
|
MAPPING = 'Mapping'
|
|
35
49
|
ALL: ClassVar[list[str]] = [MAPPING]
|
|
@@ -104,6 +118,7 @@ class BaseTransition(BaseModel):
|
|
|
104
118
|
responseField: ResponseField | None = None
|
|
105
119
|
requestFields: list[RequestField] = []
|
|
106
120
|
authentication: str | None = None
|
|
121
|
+
rateLimit: str | None = None
|
|
107
122
|
responseType: str = ResponseTypes.JSON
|
|
108
123
|
|
|
109
124
|
|
|
@@ -163,6 +178,8 @@ class KibaApi(BaseModel):
|
|
|
163
178
|
enums: list[ApiEnum] = []
|
|
164
179
|
oneOfs: list[ApiOneOf] = []
|
|
165
180
|
defaultAuthentication: str | None = None
|
|
181
|
+
rateLimits: list[ApiRateLimit] = []
|
|
182
|
+
defaultRateLimit: str | None = None
|
|
166
183
|
|
|
167
184
|
@property
|
|
168
185
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|