geniespec 0.1.2.dev9__tar.gz → 0.1.2.dev11__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.dev9 → geniespec-0.1.2.dev11}/PKG-INFO +62 -2
  2. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/README.md +61 -1
  3. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/pyproject.toml +2 -6
  4. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/pyproject.toml.orig +2 -7
  5. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/api_schema.yaml +94 -0
  6. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/genie.py +63 -16
  7. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/languages.py +121 -18
  8. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/renderable_types.py +19 -1
  9. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/v{version}/api.py.j2 +15 -3
  10. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/v{version}/endpoints.py.j2 +14 -5
  11. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/v{version}/exceptions.py.j2 +13 -1
  12. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/v{version}/internal.py.j2 +11 -2
  13. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/v{version}/resources.py.j2 +15 -2
  14. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/typescript-client/{service}/v{version}/client.ts.j2 +6 -0
  15. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/typescript-client/{service}/v{version}/endpoints.ts.j2 +3 -1
  16. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/typescript-client/{service}/v{version}/index.ts.j2 +2 -0
  17. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/typescript-client/{service}/v{version}/resources.ts.j2 +5 -4
  18. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/types.py +38 -1
  19. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/__init__.py +0 -0
  20. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/cli.py +0 -0
  21. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/__init__.py.j2 +0 -0
  22. {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/v{version}/__init__.py.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: geniespec
3
- Version: 0.1.2.dev9
3
+ Version: 0.1.2.dev11
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,64 @@ 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
+ `type: BigInt` is an integer of any size that travels as a JSON string, because JSON numbers past 2^53 lose precision in JavaScript. `python-server` types it as `int` (a `BigInt` alias in `resources`), accepts `"123"` or `123` in requests, always writes it as a string and documents it as `{"type": "string"}`; `typescript-client` types it as `bigint`, parsed with `BigInt(...)` and sent with `String(...)`.
74
+
75
+ ## Rate limits
76
+
77
+ 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:
78
+
79
+ ```yaml
80
+ rateLimits:
81
+ - name: standard-read
82
+ perMinute: 30
83
+ perHour: 300
84
+ - name: public-read
85
+ perMinute: 30
86
+ perHour: 300
87
+ keyBy: ip
88
+ defaultRateLimit: standard-read
89
+ transitions:
90
+ - action: GET-HEALTH
91
+ method: GET
92
+ authentication: none
93
+ rateLimit: public-read
94
+ ```
95
+
96
+ 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.
97
+
98
+ ## API docs
99
+
100
+ Transitions can set `operationId`, `summary`, `description`, `tags` and `extensions`, and attributes, request fields and response fields can set a `description`:
101
+
102
+ ```yaml
103
+ resources:
104
+ - singleName: Member
105
+ collectionName: Members
106
+ attributes:
107
+ - name: member-id
108
+ type: Uuid
109
+ isPrimary: true
110
+ description: The member's id.
111
+ transitions:
112
+ - action: GET
113
+ operationId: getMember
114
+ summary: Get a member
115
+ description: Returns one member.
116
+ tags: [Members]
117
+ extensions: {deprecated: true}
118
+ ```
119
+
120
+ `python-server` passes the transition fields to kiba-core's `route`, whose `OpenApiSchemaGenerator` documents every route with an `operationId` (and skips the rest); `extensions` reach `OpenApiExtension`s as `metadata['extensions']`. Field descriptions become pydantic `Field(description=...)`, so they show up in the request and response schemas. Each `operationId` must be unique, so a transition that generates more than one route (for example `GET` on a resource with unique attributes) can't set one. `typescript-client` ignores these fields.
121
+
122
+ ## Overrides
123
+
124
+ `overrides` keep an API that existed before genie working the same way once it moves to genie. They are meant to be dropped in the next major version of that API.
125
+
126
+ - `shouldPrefixParentName: false` on a child resource names it without its parent's name: `Agent` under `User` stays `Agent` (not `UserAgent`), so its primary attribute is `agent-id` rather than `user-agent-id` and its fields keep their names. Its path is unchanged (`/users/{userId}/agents/{agentId}`).
127
+ - `responseFieldName` on a `GET`, `LIST`, `CREATE` or `UPDATE` transition renames the field its response puts the resource in (for example `memories` instead of `agentMemories`). Custom transitions name it with `responseField`.
128
+
71
129
  # Development
72
130
 
73
131
  ```
@@ -82,7 +140,9 @@ make install-typescript-client
82
140
  make test-typescript-client
83
141
  ```
84
142
 
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.
143
+ 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.
144
+
145
+ `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
146
 
87
147
  # Things that need work
88
148
 
@@ -54,6 +54,64 @@ 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
+ `type: BigInt` is an integer of any size that travels as a JSON string, because JSON numbers past 2^53 lose precision in JavaScript. `python-server` types it as `int` (a `BigInt` alias in `resources`), accepts `"123"` or `123` in requests, always writes it as a string and documents it as `{"type": "string"}`; `typescript-client` types it as `bigint`, parsed with `BigInt(...)` and sent with `String(...)`.
60
+
61
+ ## Rate limits
62
+
63
+ 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:
64
+
65
+ ```yaml
66
+ rateLimits:
67
+ - name: standard-read
68
+ perMinute: 30
69
+ perHour: 300
70
+ - name: public-read
71
+ perMinute: 30
72
+ perHour: 300
73
+ keyBy: ip
74
+ defaultRateLimit: standard-read
75
+ transitions:
76
+ - action: GET-HEALTH
77
+ method: GET
78
+ authentication: none
79
+ rateLimit: public-read
80
+ ```
81
+
82
+ 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.
83
+
84
+ ## API docs
85
+
86
+ Transitions can set `operationId`, `summary`, `description`, `tags` and `extensions`, and attributes, request fields and response fields can set a `description`:
87
+
88
+ ```yaml
89
+ resources:
90
+ - singleName: Member
91
+ collectionName: Members
92
+ attributes:
93
+ - name: member-id
94
+ type: Uuid
95
+ isPrimary: true
96
+ description: The member's id.
97
+ transitions:
98
+ - action: GET
99
+ operationId: getMember
100
+ summary: Get a member
101
+ description: Returns one member.
102
+ tags: [Members]
103
+ extensions: {deprecated: true}
104
+ ```
105
+
106
+ `python-server` passes the transition fields to kiba-core's `route`, whose `OpenApiSchemaGenerator` documents every route with an `operationId` (and skips the rest); `extensions` reach `OpenApiExtension`s as `metadata['extensions']`. Field descriptions become pydantic `Field(description=...)`, so they show up in the request and response schemas. Each `operationId` must be unique, so a transition that generates more than one route (for example `GET` on a resource with unique attributes) can't set one. `typescript-client` ignores these fields.
107
+
108
+ ## Overrides
109
+
110
+ `overrides` keep an API that existed before genie working the same way once it moves to genie. They are meant to be dropped in the next major version of that API.
111
+
112
+ - `shouldPrefixParentName: false` on a child resource names it without its parent's name: `Agent` under `User` stays `Agent` (not `UserAgent`), so its primary attribute is `agent-id` rather than `user-agent-id` and its fields keep their names. Its path is unchanged (`/users/{userId}/agents/{agentId}`).
113
+ - `responseFieldName` on a `GET`, `LIST`, `CREATE` or `UPDATE` transition renames the field its response puts the resource in (for example `memories` instead of `agentMemories`). Custom transitions name it with `responseField`.
114
+
57
115
  # Development
58
116
 
59
117
  ```
@@ -68,7 +126,9 @@ make install-typescript-client
68
126
  make test-typescript-client
69
127
  ```
70
128
 
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.
129
+ 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.
130
+
131
+ `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
132
 
73
133
  # Things that need work
74
134
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "geniespec"
3
- version = "0.1.2.dev9"
3
+ version = "0.1.2.dev11"
4
4
  description = "Generate API code from an api.yaml spec"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,7 +21,7 @@ repository = "https://github.com/kibalabs/genie"
21
21
 
22
22
  [dependency-groups]
23
23
  dev = [
24
- "kiba-build==0.1.11.dev15",
24
+ "kiba-build==0.1.11.dev24",
25
25
  "kiba-core[core-api,requester]>=0.5.3.dev87,<0.5.4",
26
26
  ]
27
27
 
@@ -31,7 +31,3 @@ build-backend = "uv_build"
31
31
 
32
32
  [tool.pytest.ini_options]
33
33
  pythonpath = ["src"]
34
-
35
- [tool.bumpversion]
36
- search = 'version = "{current_version}"'
37
- replace = 'version = "{new_version}"'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "geniespec"
3
- version = "0.1.2.dev9"
3
+ version = "0.1.2.dev11"
4
4
  description = "Generate API code from an api.yaml spec"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,7 +21,7 @@ repository = "https://github.com/kibalabs/genie"
21
21
 
22
22
  [dependency-groups]
23
23
  dev = [
24
- "kiba-build==0.1.11.dev15",
24
+ "kiba-build==0.1.11.dev24",
25
25
  "kiba-core[core-api,requester]>=0.5.3.dev87,<0.5.4",
26
26
  ]
27
27
 
@@ -31,8 +31,3 @@ build-backend = "uv_build"
31
31
 
32
32
  [tool.pytest.ini_options]
33
33
  pythonpath = ["src"]
34
-
35
- # NOTE(krishan711): without this, bumping the version also rewrites dependency pins that contain it (e.g. 0.1.1 inside kiba-build==0.1.11.dev15)
36
- [tool.bumpversion]
37
- search = "version = \"{current_version}\""
38
- replace = "version = \"{new_version}\""
@@ -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
@@ -123,6 +158,8 @@ definitions:
123
158
  type: boolean
124
159
  isDeprecated:
125
160
  type: boolean
161
+ description:
162
+ type: string
126
163
  defaultValue:
127
164
  type:
128
165
  - string
@@ -171,6 +208,12 @@ definitions:
171
208
  type: array
172
209
  items:
173
210
  type: string
211
+ overrides:
212
+ type: object
213
+ additionalProperties: false
214
+ properties:
215
+ shouldPrefixParentName:
216
+ type: boolean
174
217
  BaseTransition:
175
218
  type: object
176
219
  additionalProperties: false
@@ -192,6 +235,11 @@ definitions:
192
235
  responseField:
193
236
  $ref: '#/definitions/ResponseField'
194
237
  authentication:
238
+ type:
239
+ - "null"
240
+ - string
241
+ pattern: '^[a-z][a-z0-9_-]*$'
242
+ rateLimit:
195
243
  type:
196
244
  - "null"
197
245
  - string
@@ -202,6 +250,21 @@ definitions:
202
250
  - json
203
251
  - stream
204
252
  - redirect
253
+ operationId:
254
+ type: string
255
+ pattern: '^[A-Za-z][A-Za-z0-9_]*$'
256
+ summary:
257
+ type: string
258
+ description:
259
+ type: string
260
+ tags:
261
+ type: array
262
+ items:
263
+ type: string
264
+ extensions:
265
+ type: object
266
+ overrides:
267
+ $ref: '#/definitions/TransitionOverrides'
205
268
  Transition:
206
269
  type: object
207
270
  additionalProperties: false
@@ -223,6 +286,11 @@ definitions:
223
286
  responseField:
224
287
  $ref: '#/definitions/ResponseField'
225
288
  authentication:
289
+ type:
290
+ - "null"
291
+ - string
292
+ pattern: '^[a-z][a-z0-9_-]*$'
293
+ rateLimit:
226
294
  type:
227
295
  - "null"
228
296
  - string
@@ -240,6 +308,28 @@ definitions:
240
308
  - collection
241
309
  isIndirectAction:
242
310
  type: boolean
311
+ operationId:
312
+ type: string
313
+ pattern: '^[A-Za-z][A-Za-z0-9_]*$'
314
+ summary:
315
+ type: string
316
+ description:
317
+ type: string
318
+ tags:
319
+ type: array
320
+ items:
321
+ type: string
322
+ extensions:
323
+ type: object
324
+ overrides:
325
+ $ref: '#/definitions/TransitionOverrides'
326
+ TransitionOverrides:
327
+ type: object
328
+ additionalProperties: false
329
+ properties:
330
+ responseFieldName:
331
+ type: string
332
+ pattern: '^[a-z][A-Za-z0-9-]*$'
243
333
  RootTransition:
244
334
  type: object
245
335
  allOf:
@@ -283,6 +373,8 @@ definitions:
283
373
  - 'null'
284
374
  isDeprecated:
285
375
  type: boolean
376
+ description:
377
+ type: string
286
378
  maxValue:
287
379
  type:
288
380
  - string
@@ -329,6 +421,8 @@ definitions:
329
421
  type: boolean
330
422
  isList:
331
423
  type: boolean
424
+ description:
425
+ type: string
332
426
  Exception:
333
427
  type: object
334
428
  additionalProperties: false
@@ -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
  )
@@ -196,6 +199,7 @@ class Renderer:
196
199
  isRequired=requestField.isRequired,
197
200
  isDeprecated=requestField.isDeprecated,
198
201
  source=requestField.source,
202
+ description=requestField.description,
199
203
  )
200
204
 
201
205
  def _response_field_to_renderable_field(self, responseField: ResponseField, knownTypeNames: dict[str, str]) -> RenderableField:
@@ -211,6 +215,7 @@ class Renderer:
211
215
  mappingValueTypes=responseField.mappingValueTypes,
212
216
  enumValues=responseField.enumValues,
213
217
  ),
218
+ description=responseField.description,
214
219
  )
215
220
 
216
221
  def _attribute_to_renderable_attribute(self, attribute: Attribute, knownTypeNames: dict[str, str], isNullable: bool | None = None) -> RenderableAttribute:
@@ -233,6 +238,7 @@ class Renderer:
233
238
  isAccessibleIndependently=attribute.isAccessibleIndependently,
234
239
  isSettable=attribute.isSettable,
235
240
  isNullable=attribute.isNullable,
241
+ description=attribute.description,
236
242
  )
237
243
 
238
244
  def _attribute_to_renderable_parameter(self, attribute: Attribute, isRequired: bool, knownTypeNames: dict[str, str], shouldUseDefault: bool = True) -> RenderableParameter:
@@ -251,11 +257,25 @@ class Renderer:
251
257
  defaultValue=attribute.defaultValue if shouldUseDefault else None,
252
258
  isRequired=isRequired,
253
259
  isDeprecated=attribute.isDeprecated,
260
+ description=attribute.description,
254
261
  )
255
262
 
263
+ @staticmethod
264
+ def _operation_documentation(transition: BaseTransition) -> dict[str, object]:
265
+ return {'operationId': transition.operationId, 'summary': transition.summary, 'description': transition.description, 'tags': transition.tags, 'extensions': transition.extensions}
266
+
267
+ def _with_transition_fields(self, operation: RenderableOperation, transition: BaseTransition, isStandard: bool) -> RenderableOperation:
268
+ update = self._operation_documentation(transition=transition)
269
+ responseFieldName = transition.overrides.responseFieldName
270
+ if responseFieldName is not None:
271
+ if not isStandard or not operation.responseFields:
272
+ raise SemanticValidationException(message=f'Only GET, LIST, CREATE and UPDATE transitions can override responseFieldName: {transition.action}')
273
+ update['responseFields'] = [field.model_copy(update={'name': RenderableString.from_kebab_case_string(string=responseFieldName)}) for field in operation.responseFields]
274
+ return operation.model_copy(update=update)
275
+
256
276
  @staticmethod
257
277
  def _resource_full_name(resource: Resource, name: str) -> str:
258
- return f'{resource.parentResource or ""}{name}'
278
+ return f'{resource.parentResource or ""}{name}' if resource.overrides.shouldPrefixParentName else name
259
279
 
260
280
  def _resource_to_renderable_resource(self, resource: Resource, knownTypeNames: dict[str, str]) -> RenderableResource:
261
281
  attributes = [self._attribute_to_renderable_attribute(attribute=attribute, knownTypeNames=knownTypeNames) for attribute in resource.attributes]
@@ -287,7 +307,9 @@ class Renderer:
287
307
 
288
308
  @staticmethod
289
309
  def _relative_name(name: RenderableString, anchor: RenderableResource | None) -> RenderableString:
290
- return RenderableString(string=name.string[len(anchor.singleName.string) :]) if anchor is not None else name
310
+ if anchor is None or name.string[: len(anchor.singleName.string)] != anchor.singleName.string:
311
+ return name
312
+ return RenderableString(string=name.string[len(anchor.singleName.string) :])
291
313
 
292
314
  def _resource_component(self, resource: RenderableResource, attribute: RenderableAttribute | None, anchor: RenderableResource | None) -> ResourceEndpointComponent:
293
315
  return ResourceEndpointComponent(
@@ -344,7 +366,7 @@ class Renderer:
344
366
  def _is_string_mapping(renderableType: RenderableType) -> bool:
345
367
  return isinstance(renderableType, MappingRenderableType) and renderableType.keyType.rawType == PrimitiveTypes.STRING and renderableType.valueType.rawType == PrimitiveTypes.STRING
346
368
 
347
- def _operation_errors(self, operation: RenderableOperation) -> list[str]:
369
+ def _operation_errors(self, operation: RenderableOperation, rateLimitsByName: dict[str, RenderableRateLimit]) -> list[str]:
348
370
  lastComponent = operation.endpoint.components[-1] if operation.endpoint.components else None
349
371
  name = lastComponent.actionName.kebabCaseString if isinstance(lastComponent, ActionEndpointComponent) and lastComponent.actionName is not None else operation.method.method.upperCaseString
350
372
  parametersBySource: dict[str, list[RenderableParameter]] = collections.defaultdict(list)
@@ -369,6 +391,12 @@ class Renderer:
369
391
  for parameter in operation.parameters
370
392
  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
393
  ]
394
+ if operation.rateLimit not in (None, 'none'):
395
+ rateLimit = rateLimitsByName.get(operation.rateLimit)
396
+ if rateLimit is None:
397
+ errors.append(f'{name}: unknown rate limit {operation.rateLimit}')
398
+ elif rateLimit.keyBy == RateLimitKeys.USER and operation.authentication in (None, 'none'):
399
+ errors.append(f'{name}: rate limit {operation.rateLimit} is keyed by user, so the transition needs authentication')
372
400
  return errors
373
401
 
374
402
  @staticmethod
@@ -414,6 +442,9 @@ class Renderer:
414
442
  }
415
443
  resourceErrors = [f'Duplicate type name: {name}' for name, count in collections.Counter(fullResourceNames + enumNames + oneOfNames).items() if count > 1]
416
444
  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]
445
+ resourceErrors += [f'Duplicate rate limit: {name}' for name, count in collections.Counter(rateLimit.name for rateLimit in api.rateLimits).items() if count > 1]
446
+ resourceErrors += ['Rate limit none is reserved for transitions without a rate limit' for rateLimit in api.rateLimits if rateLimit.name == 'none']
447
+ 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
448
  resourcesByName = dict(zip(fullResourceNames, api.resources, strict=False))
418
449
  renderableOneOfs: list[RenderableOneOf] = []
419
450
  for oneOf in api.oneOfs:
@@ -440,11 +471,16 @@ class Renderer:
440
471
  if resourceErrors:
441
472
  raise SemanticValidationException(message='\n'.join(resourceErrors))
442
473
  renderableOperations = [
443
- self._action_operation(
474
+ self._with_transition_fields(
475
+ operation=self._action_operation(
476
+ transition=transition,
477
+ endpoint=RenderableEndpoint(components=[ActionEndpointComponent(actionName=RenderableString.from_separated_string(string=transition.action, separator='-'))]),
478
+ knownTypeNames=knownTypeNames,
479
+ defaultAuthentication=api.defaultAuthentication,
480
+ defaultRateLimit=api.defaultRateLimit,
481
+ ),
444
482
  transition=transition,
445
- endpoint=RenderableEndpoint(components=[ActionEndpointComponent(actionName=RenderableString.from_separated_string(string=transition.action, separator='-'))]),
446
- knownTypeNames=knownTypeNames,
447
- defaultAuthentication=api.defaultAuthentication,
483
+ isStandard=False,
448
484
  )
449
485
  for transition in api.transitions
450
486
  ]
@@ -475,6 +511,10 @@ class Renderer:
475
511
  exceptions=renderableExceptions,
476
512
  enums=[RenderableEnum(name=RenderableString.from_pascal_case_string(string=enum.name), values=enum.values) for enum in api.enums],
477
513
  oneOfs=renderableOneOfs,
514
+ rateLimits=[
515
+ 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)
516
+ for rateLimit in api.rateLimits
517
+ ],
478
518
  )
479
519
  renderableResources: list[RenderableResource] = [self._resource_to_renderable_resource(resource=resource, knownTypeNames=knownTypeNames) for resource in api.resources]
480
520
  uncoveredTransitions: list[str] = []
@@ -482,6 +522,8 @@ class Renderer:
482
522
  processableResource = self._get_processable_resource(renderableResource=renderableResource, allResources=renderableResources, knownTypeNames=knownTypeNames)
483
523
  endpoints = self._get_resource_endpoints(processableResource=processableResource)
484
524
  for transition in resource.transitions:
525
+ authentication = transition.authentication or api.defaultAuthentication
526
+ rateLimit = transition.rateLimit or api.defaultRateLimit
485
527
  operationCount = len(renderableApi.operations)
486
528
  method: RenderableMethod = self._transition_to_renderable_method(transition=transition)
487
529
  if transition.isIndirectAction and transition.action.upper() in {'DELETE', 'CREATE', 'UPDATE', 'GET', 'LIST'}:
@@ -490,7 +532,7 @@ class Renderer:
490
532
  raise SemanticValidationException(message=f'Only custom transitions can set a responseType: {transition.action}')
491
533
  if transition.action.upper() == 'DELETE':
492
534
  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))
535
+ renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=None, responseFields=[], authentication=authentication, rateLimit=rateLimit))
494
536
  elif transition.action.upper() == 'CREATE':
495
537
  parentResource = processableResource.parent.processableResource if processableResource.parent is not None else None
496
538
  isOneToOne = (processableResource.renderableResource.primaryAttribute == parentResource.renderableResource.primaryAttribute) if parentResource is not None else False
@@ -502,17 +544,17 @@ class Renderer:
502
544
  body += [self._request_field_to_renderable_parameter(requestField=requestField, knownTypeNames=knownTypeNames) for requestField in transition.requestFields]
503
545
  returnValue = RenderableField(name=processableResource.renderableResource.singleName, fieldType=RenderableType(rawType='class', className=processableResource.renderableResource.singleName, isNullable=False, isList=False))
504
546
  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))
547
+ renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=body or None, responseFields=[returnValue], authentication=authentication, rateLimit=rateLimit))
506
548
  elif transition.action.upper() == 'UPDATE':
507
549
  returnValue = RenderableField(name=processableResource.renderableResource.singleName, fieldType=RenderableType(rawType='class', className=processableResource.renderableResource.singleName, isNullable=False, isList=False))
508
550
  body = [self._attribute_to_renderable_parameter(attribute=attribute, isRequired=False, knownTypeNames=knownTypeNames, shouldUseDefault=False) for attribute in resource.attributes if attribute.isUpdatable]
509
551
  body += [self._request_field_to_renderable_parameter(requestField=requestField, knownTypeNames=knownTypeNames) for requestField in transition.requestFields]
510
552
  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))
553
+ renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=body or None, responseFields=[returnValue], authentication=authentication, rateLimit=rateLimit))
512
554
  elif transition.action.upper() == 'GET':
513
555
  returnValue = RenderableField(name=processableResource.renderableResource.singleName, fieldType=RenderableType(rawType='class', className=processableResource.renderableResource.singleName, isNullable=False, isList=False))
514
556
  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))
557
+ renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=None, body=None, responseFields=[returnValue], authentication=authentication, rateLimit=rateLimit))
516
558
  endpointsAccessibleWithUnique = [endpoint for endpoint in endpoints.collectionEndpoints if isinstance(endpoint.components[-1], ResourceEndpointComponent)]
517
559
  uniqueAttributes = [attribute for attribute in resource.attributes if attribute.isUnique and not attribute.isPrimary and not attribute.shouldDisableGetEndpoint and not attribute.isAccessibleIndependently]
518
560
  for endpoint in endpointsAccessibleWithUnique:
@@ -529,12 +571,12 @@ class Renderer:
529
571
  ),
530
572
  ]
531
573
  )
532
- renderableApi.operations.append(RenderableOperation(method=method, endpoint=getByUniqueEndpoint, query=None, body=None, responseFields=[returnValue], authentication=transition.authentication or api.defaultAuthentication))
574
+ renderableApi.operations.append(RenderableOperation(method=method, endpoint=getByUniqueEndpoint, query=None, body=None, responseFields=[returnValue], authentication=authentication, rateLimit=rateLimit))
533
575
  elif transition.action.upper() == 'LIST':
534
576
  returnValue = RenderableField(name=processableResource.renderableResource.collectionName, fieldType=RenderableType(rawType='class', className=processableResource.renderableResource.singleName, isNullable=False, isList=True))
535
577
  query = [self._request_field_to_renderable_parameter(requestField=requestField, knownTypeNames=knownTypeNames) for requestField in transition.requestFields]
536
578
  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))
579
+ renderableApi.operations.append(RenderableOperation(method=method, endpoint=endpoint, query=query or None, body=None, responseFields=[returnValue], authentication=authentication, rateLimit=rateLimit))
538
580
  else: # Else it's a custom action
539
581
  if transition.operatesOn == 'individual':
540
582
  customEndpoints = endpoints.individualEndpoints
@@ -544,15 +586,20 @@ class Renderer:
544
586
  raise KibaException(message=f'Resource {processableResource.renderableResource.singleName.pascalCaseString} has custom transition with invalid operatesOn field: {transition.operatesOn}')
545
587
  for endpoint in customEndpoints:
546
588
  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))
589
+ renderableApi.operations.append(self._action_operation(transition=transition, endpoint=customEndpoint, knownTypeNames=knownTypeNames, defaultAuthentication=api.defaultAuthentication, defaultRateLimit=api.defaultRateLimit, isIndirectAction=transition.isIndirectAction))
590
+ isStandard = transition.action.upper() in {'CREATE', 'UPDATE', 'GET', 'LIST'}
591
+ renderableApi.operations[operationCount:] = [self._with_transition_fields(operation=operation, transition=transition, isStandard=isStandard) for operation in renderableApi.operations[operationCount:]]
548
592
  if len(renderableApi.operations) == operationCount:
549
593
  uncoveredTransitions.append(f'{renderableResource.singleName.pascalCaseString} {transition.action}')
550
594
  if uncoveredTransitions:
551
595
  raise SemanticValidationException(
552
596
  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
597
  )
554
- operationErrors = [error for operation in renderableApi.operations for error in self._operation_errors(operation=operation)]
598
+ rateLimitsByName = {rateLimit.name.kebabCaseString: rateLimit for rateLimit in renderableApi.rateLimits}
599
+ operationErrors = [error for operation in renderableApi.operations for error in self._operation_errors(operation=operation, rateLimitsByName=rateLimitsByName)]
555
600
  operationErrors += self._file_errors(resources=renderableResources, exceptions=renderableExceptions)
601
+ operationIdCounts = collections.Counter(operation.operationId for operation in renderableApi.operations if operation.operationId is not None)
602
+ operationErrors += [f'Duplicate operationId: {operationId}' for operationId, count in operationIdCounts.items() if count > 1]
556
603
  if operationErrors:
557
604
  raise SemanticValidationException(message='\n'.join(operationErrors))
558
605
  renderableApi.resources.extend(renderableResources)