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.
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/PKG-INFO +62 -2
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/README.md +61 -1
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/pyproject.toml +2 -6
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/pyproject.toml.orig +2 -7
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/api_schema.yaml +94 -0
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/genie.py +63 -16
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/languages.py +121 -18
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/renderable_types.py +19 -1
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/v{version}/api.py.j2 +15 -3
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/v{version}/endpoints.py.j2 +14 -5
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/v{version}/exceptions.py.j2 +13 -1
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/v{version}/internal.py.j2 +11 -2
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/v{version}/resources.py.j2 +15 -2
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/typescript-client/{service}/v{version}/client.ts.j2 +6 -0
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/typescript-client/{service}/v{version}/endpoints.ts.j2 +3 -1
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/typescript-client/{service}/v{version}/index.ts.j2 +2 -0
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/typescript-client/{service}/v{version}/resources.ts.j2 +5 -4
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/types.py +38 -1
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/__init__.py +0 -0
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/cli.py +0 -0
- {geniespec-0.1.2.dev9 → geniespec-0.1.2.dev11}/src/geniespec/templates/python-server/{service}/__init__.py.j2 +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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)
|