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.
Files changed (22) hide show
  1. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/PKG-INFO +27 -2
  2. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/README.md +26 -1
  3. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/pyproject.toml +1 -1
  4. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/pyproject.toml.orig +1 -1
  5. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/api_schema.yaml +45 -0
  6. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/genie.py +30 -10
  7. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/languages.py +15 -0
  8. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/renderable_types.py +11 -0
  9. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/v{version}/api.py.j2 +8 -1
  10. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/types.py +17 -0
  11. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/__init__.py +0 -0
  12. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/cli.py +0 -0
  13. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/__init__.py.j2 +0 -0
  14. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/v{version}/__init__.py.j2 +0 -0
  15. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/v{version}/endpoints.py.j2 +0 -0
  16. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/v{version}/exceptions.py.j2 +0 -0
  17. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/v{version}/internal.py.j2 +0 -0
  18. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/python-server/{service}/v{version}/resources.py.j2 +0 -0
  19. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/typescript-client/{service}/v{version}/client.ts.j2 +0 -0
  20. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/typescript-client/{service}/v{version}/endpoints.ts.j2 +0 -0
  21. {geniespec-0.1.1.dev8 → geniespec-0.1.2.dev4}/src/geniespec/templates/typescript-client/{service}/v{version}/index.ts.j2 +0 -0
  22. {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.1.dev8
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "geniespec"
3
- version = "0.1.1.dev8"
3
+ version = "0.1.2.dev4"
4
4
  description = "Generate API code from an api.yaml spec"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "geniespec"
3
- version = "0.1.1.dev8"
3
+ version = "0.1.2.dev4"
4
4
  description = "Generate API code from an api.yaml spec"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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=transition.authentication or api.defaultAuthentication))
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=transition.authentication or api.defaultAuthentication))
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=transition.authentication or api.defaultAuthentication))
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=transition.authentication or api.defaultAuthentication))
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=transition.authentication or api.defaultAuthentication))
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=transition.authentication or api.defaultAuthentication))
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
- operationErrors = [error for operation in renderableApi.operations for error in self._operation_errors(operation=operation)]
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