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.
Files changed (22) hide show
  1. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/PKG-INFO +26 -1
  2. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/README.md +25 -0
  3. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/pyproject.toml +1 -1
  4. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/pyproject.toml.orig +1 -1
  5. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/api_schema.yaml +45 -0
  6. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/genie.py +30 -10
  7. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/languages.py +42 -4
  8. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/renderable_types.py +11 -0
  9. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/v{version}/api.py.j2 +8 -1
  10. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/v{version}/endpoints.py.j2 +3 -0
  11. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/v{version}/exceptions.py.j2 +3 -0
  12. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/v{version}/internal.py.j2 +3 -0
  13. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/v{version}/resources.py.j2 +3 -0
  14. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/types.py +19 -1
  15. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/__init__.py +0 -0
  16. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/cli.py +0 -0
  17. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/__init__.py.j2 +0 -0
  18. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/python-server/{service}/v{version}/__init__.py.j2 +0 -0
  19. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/typescript-client/{service}/v{version}/client.ts.j2 +0 -0
  20. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/typescript-client/{service}/v{version}/endpoints.ts.j2 +0 -0
  21. {geniespec-0.1.2.dev1 → geniespec-0.1.2.dev5}/src/geniespec/templates/typescript-client/{service}/v{version}/index.ts.j2 +0 -0
  22. {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.dev1
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
  ```
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "geniespec"
3
- version = "0.1.2.dev1"
3
+ version = "0.1.2.dev5"
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.2.dev1"
3
+ version = "0.1.2.dev5"
4
4
  description = "Generate API code from an api.yaml spec"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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=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
@@ -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 string.')
231
- output.append(self.path_parameter_segment(parameterName=self.identifier(name=component.attribute.name), isInteger=component.attribute.fieldType.rawType != PrimitiveTypes.STRING))
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 == PrimitiveTypes.STRING:
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
@@ -2,6 +2,9 @@
2
2
  from __future__ import annotations
3
3
  import collections.abc
4
4
  import typing
5
+ {% if target.requires_uuid(api=api) %}
6
+ import uuid
7
+ {% endif %}
5
8
  {% if target.exceptions_require_json(api=api) %}
6
9
  import pydantic
7
10
  {% endif %}
@@ -5,6 +5,9 @@ import collections.abc
5
5
  {% if target.operations_require_typing(api=api) %}
6
6
  import typing
7
7
  {% endif %}
8
+ {% if target.requires_uuid(api=api) %}
9
+ import uuid
10
+ {% endif %}
8
11
 
9
12
  {% if target.operations_require_json(api=api) %}
10
13
  import pydantic
@@ -6,6 +6,9 @@ import datetime
6
6
  {% if target.resources_require_typing(api=api) %}
7
7
  import typing
8
8
  {% endif %}
9
+ {% if target.requires_uuid(api=api) %}
10
+ import uuid
11
+ {% endif %}
9
12
 
10
13
  {% if target.resources_require_json(api=api) or api.oneOfs %}
11
14
  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