apppy-fastql 0.1.0__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.
- apppy_fastql-0.1.0/.gitignore +28 -0
- apppy_fastql-0.1.0/PKG-INFO +15 -0
- apppy_fastql-0.1.0/README.md +0 -0
- apppy_fastql-0.1.0/fastql.mk +23 -0
- apppy_fastql-0.1.0/pyproject.toml +29 -0
- apppy_fastql-0.1.0/src/apppy/fastql/__init__.py +681 -0
- apppy_fastql-0.1.0/src/apppy/fastql/annotation/__init__.py +7 -0
- apppy_fastql-0.1.0/src/apppy/fastql/annotation/error.py +15 -0
- apppy_fastql-0.1.0/src/apppy/fastql/annotation/id.py +18 -0
- apppy_fastql-0.1.0/src/apppy/fastql/annotation/input.py +15 -0
- apppy_fastql-0.1.0/src/apppy/fastql/annotation/interface.py +15 -0
- apppy_fastql-0.1.0/src/apppy/fastql/annotation/mutation.py +84 -0
- apppy_fastql-0.1.0/src/apppy/fastql/annotation/output.py +30 -0
- apppy_fastql-0.1.0/src/apppy/fastql/annotation/query.py +86 -0
- apppy_fastql-0.1.0/src/apppy/fastql/errors.py +43 -0
- apppy_fastql-0.1.0/src/apppy/fastql/permissions.py +27 -0
- apppy_fastql-0.1.0/src/apppy/fastql/typed_id.py +101 -0
- apppy_fastql-0.1.0/src/apppy/fastql/typed_id_unit_test.py +70 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
__generated__/
|
|
2
|
+
dist/
|
|
3
|
+
*.egg-info
|
|
4
|
+
.env
|
|
5
|
+
.env.*
|
|
6
|
+
*.env
|
|
7
|
+
!.env.ci
|
|
8
|
+
.file_store/
|
|
9
|
+
*.pid
|
|
10
|
+
.python-version
|
|
11
|
+
*.secrets
|
|
12
|
+
.secrets
|
|
13
|
+
*.tar.gz
|
|
14
|
+
*.test_output/
|
|
15
|
+
.test_output/
|
|
16
|
+
uv.lock
|
|
17
|
+
*.whl
|
|
18
|
+
|
|
19
|
+
# System files
|
|
20
|
+
__pycache__
|
|
21
|
+
.DS_Store
|
|
22
|
+
|
|
23
|
+
# Editor files
|
|
24
|
+
*.sublime-project
|
|
25
|
+
*.sublime-workspace
|
|
26
|
+
.vscode/*
|
|
27
|
+
!.vscode/settings.json
|
|
28
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apppy-fastql
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Annotations and types to support GraphQL for server development
|
|
5
|
+
Project-URL: Homepage, https://github.com/spals/apppy
|
|
6
|
+
Author: Tim Kral
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: apppy-env>=0.1.0
|
|
12
|
+
Requires-Dist: apppy-logger>=0.1.0
|
|
13
|
+
Requires-Dist: inflection==0.5.1
|
|
14
|
+
Requires-Dist: sqids==0.5.0
|
|
15
|
+
Requires-Dist: strawberry-graphql[fastapi]==0.275.5
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
ifndef APPPY_FASTQL_MK_INCLUDED
|
|
2
|
+
APPPY_FASTQL_MK_INCLUDED := 1
|
|
3
|
+
FASTQL_PKG_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
|
4
|
+
|
|
5
|
+
.PHONY: fastql fastql-dev fastql/build fastql/clean fastql/install fastql/install-dev
|
|
6
|
+
|
|
7
|
+
fastql: fastql/clean fastql/install
|
|
8
|
+
|
|
9
|
+
fastql-dev: fastql/clean fastql/install-dev
|
|
10
|
+
|
|
11
|
+
fastql/build:
|
|
12
|
+
cd $(FASTQL_PKG_DIR) && uvx --from build pyproject-build
|
|
13
|
+
|
|
14
|
+
fastql/clean:
|
|
15
|
+
cd $(FASTQL_PKG_DIR) && rm -rf dist/ *.egg-info .venv
|
|
16
|
+
|
|
17
|
+
fastql/install: fastql/build
|
|
18
|
+
cd $(FASTQL_PKG_DIR) && uv pip install dist/*.whl
|
|
19
|
+
|
|
20
|
+
fastql/install-dev:
|
|
21
|
+
cd $(FASTQL_PKG_DIR) && uv pip install -e .
|
|
22
|
+
|
|
23
|
+
endif # APPPY_FASTQL_MK_INCLUDED
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.25"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "apppy-fastql"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Annotations and types to support GraphQL for server development"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{ name = "Tim Kral" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"apppy-env>=0.1.0",
|
|
19
|
+
"apppy-logger>=0.1.0",
|
|
20
|
+
"inflection==0.5.1",
|
|
21
|
+
"strawberry-graphql[fastapi]==0.275.5",
|
|
22
|
+
"sqids==0.5.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/spals/apppy"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/apppy"]
|
|
@@ -0,0 +1,681 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
from collections.abc import Awaitable, Callable
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import UnionType
|
|
6
|
+
from typing import ( # noqa: UP035
|
|
7
|
+
Annotated,
|
|
8
|
+
Any,
|
|
9
|
+
List,
|
|
10
|
+
Optional,
|
|
11
|
+
Union,
|
|
12
|
+
cast,
|
|
13
|
+
get_args,
|
|
14
|
+
get_origin,
|
|
15
|
+
get_type_hints,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
import inflection
|
|
19
|
+
import strawberry
|
|
20
|
+
from strawberry.annotation import StrawberryAnnotation
|
|
21
|
+
from strawberry.fastapi import GraphQLRouter
|
|
22
|
+
from strawberry.http.ides import GraphQL_IDE
|
|
23
|
+
from strawberry.printer import print_schema
|
|
24
|
+
from strawberry.printer.printer import PrintExtras, print_args
|
|
25
|
+
from strawberry.types.field import StrawberryField, StrawberryUnion
|
|
26
|
+
from strawberry.types.fields.resolver import StrawberryResolver
|
|
27
|
+
|
|
28
|
+
from apppy.fastql.annotation.error import valid_fastql_type_error
|
|
29
|
+
from apppy.fastql.annotation.input import valid_fastql_type_input
|
|
30
|
+
from apppy.fastql.annotation.output import extract_concrete_type, valid_fastql_type_output
|
|
31
|
+
from apppy.logger import WithLogger
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FastQL(WithLogger):
|
|
35
|
+
"""
|
|
36
|
+
A convenience class to support IOC with respect to graphql.
|
|
37
|
+
|
|
38
|
+
Its include_mutation and include_query can be used in a similar
|
|
39
|
+
way to FastAPI's include_router.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self):
|
|
43
|
+
self._mutations = []
|
|
44
|
+
self._queries = []
|
|
45
|
+
self._types_error = []
|
|
46
|
+
self._types_id = []
|
|
47
|
+
self._types_input = []
|
|
48
|
+
self._types_output = []
|
|
49
|
+
|
|
50
|
+
self._graphql_schema: strawberry.Schema | None = None
|
|
51
|
+
|
|
52
|
+
##### ##### ##### Build Schema ##### ##### #####
|
|
53
|
+
|
|
54
|
+
def _attach_fields_to_namespace(
|
|
55
|
+
self, resolvers: dict[str, Callable[..., Any]], namespace: dict[str, Any]
|
|
56
|
+
) -> None:
|
|
57
|
+
for attr_name, resolver in resolvers.items():
|
|
58
|
+
resolver_name = getattr(resolver, "__name__", "<unknown>")
|
|
59
|
+
return_type = resolver._fastql_return_type # type: ignore[attr-defined]
|
|
60
|
+
error_types = resolver._fastql_error_types # type: ignore[attr-defined]
|
|
61
|
+
permission_instances = resolver._fastql_permission_instances # type: ignore[attr-defined]
|
|
62
|
+
skip_permission_checks = resolver._skip_permission_checks # type: ignore[attr-defined]
|
|
63
|
+
|
|
64
|
+
if not valid_fastql_type_output(return_type):
|
|
65
|
+
return_type_name = getattr(return_type, "__name__", "<unknown>")
|
|
66
|
+
raise TypeError(
|
|
67
|
+
f"Return type {return_type_name} for {resolver_name} must be a valid @fastql_type_output type." # noqa: E501
|
|
68
|
+
)
|
|
69
|
+
self._register_type_output(resolver._fastql_return_type) # type: ignore[attr-defined]
|
|
70
|
+
|
|
71
|
+
sig = inspect.signature(resolver)
|
|
72
|
+
for param in sig.parameters.values():
|
|
73
|
+
if param.name in {"self", "info"}:
|
|
74
|
+
continue
|
|
75
|
+
if param.annotation is inspect.Parameter.empty:
|
|
76
|
+
continue
|
|
77
|
+
if not valid_fastql_type_input(param.annotation):
|
|
78
|
+
raise TypeError(
|
|
79
|
+
f"Parameter {param.annotation.__name__} of {resolver_name} must be a valid @fastql_type_input type." # noqa: E501
|
|
80
|
+
)
|
|
81
|
+
self._register_type_input(param.annotation)
|
|
82
|
+
|
|
83
|
+
if error_types:
|
|
84
|
+
for error_type in error_types:
|
|
85
|
+
if not valid_fastql_type_error(error_type):
|
|
86
|
+
raise TypeError(
|
|
87
|
+
f"Error type of {resolver_name} must be a valid @fastql_type_error type." # noqa: E501
|
|
88
|
+
)
|
|
89
|
+
self._register_type_error(error_type)
|
|
90
|
+
|
|
91
|
+
union_name_python = f"{attr_name}_result"
|
|
92
|
+
union_name = "".join(word.capitalize() for word in union_name_python.split("_"))
|
|
93
|
+
result_union = Annotated[
|
|
94
|
+
Union[return_type, *error_types], strawberry.union(union_name) # type: ignore[valid-type]
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
@wraps(resolver) # type: ignore[arg-type]
|
|
98
|
+
async def wrapped_resolver(
|
|
99
|
+
*args,
|
|
100
|
+
__resolver=resolver,
|
|
101
|
+
__error_types=error_types,
|
|
102
|
+
__permission_instances=permission_instances,
|
|
103
|
+
__skip_permission_checks=skip_permission_checks,
|
|
104
|
+
**kwargs,
|
|
105
|
+
):
|
|
106
|
+
try:
|
|
107
|
+
info = kwargs["info"]
|
|
108
|
+
if not __skip_permission_checks:
|
|
109
|
+
for permission in __permission_instances:
|
|
110
|
+
if not permission.has_permission(None, info):
|
|
111
|
+
return permission.graphql_client_error_class(
|
|
112
|
+
*permission.graphql_client_error_args()
|
|
113
|
+
)
|
|
114
|
+
return await __resolver(*args, **kwargs)
|
|
115
|
+
except __error_types as e:
|
|
116
|
+
return e
|
|
117
|
+
|
|
118
|
+
typed_resolver = cast(Callable[..., Any], wrapped_resolver)
|
|
119
|
+
namespace[attr_name] = StrawberryField(
|
|
120
|
+
graphql_name=inflection.camelize(attr_name, uppercase_first_letter=False),
|
|
121
|
+
python_name=attr_name,
|
|
122
|
+
base_resolver=StrawberryResolver(typed_resolver),
|
|
123
|
+
type_annotation=StrawberryAnnotation(result_union),
|
|
124
|
+
is_subscription=False,
|
|
125
|
+
)
|
|
126
|
+
else:
|
|
127
|
+
namespace[attr_name] = strawberry.field(
|
|
128
|
+
resolver,
|
|
129
|
+
name=inflection.camelize(attr_name, uppercase_first_letter=False),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
def _compose_mutations(self, name: str = "Mutation") -> Any:
|
|
133
|
+
namespace: dict[str, Any] = {}
|
|
134
|
+
|
|
135
|
+
for instance in self._mutations:
|
|
136
|
+
cls = type(instance)
|
|
137
|
+
if not getattr(cls, "_fastql_is_mutation", False):
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
resolvers = self._extract_fastql_resolvers(
|
|
141
|
+
instance, cls, expected_decorator_flag="_fastql_is_mutation"
|
|
142
|
+
)
|
|
143
|
+
self._attach_fields_to_namespace(resolvers, namespace)
|
|
144
|
+
|
|
145
|
+
return strawberry.type(type(name, (), namespace))
|
|
146
|
+
|
|
147
|
+
def _compose_queries(self, name: str = "Query") -> Any:
|
|
148
|
+
namespace: dict[str, Any] = {}
|
|
149
|
+
|
|
150
|
+
for instance in self._queries:
|
|
151
|
+
cls = type(instance)
|
|
152
|
+
if not getattr(cls, "_fastql_is_query", False):
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
resolvers = self._extract_fastql_resolvers(
|
|
156
|
+
instance, cls, expected_decorator_flag="_fastql_is_query"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
self._attach_fields_to_namespace(resolvers, namespace)
|
|
160
|
+
|
|
161
|
+
return strawberry.type(type(name, (), namespace))
|
|
162
|
+
|
|
163
|
+
def _extract_fastql_resolvers(
|
|
164
|
+
self,
|
|
165
|
+
instance: Any,
|
|
166
|
+
cls: type,
|
|
167
|
+
expected_decorator_flag: str,
|
|
168
|
+
) -> dict[str, Callable[..., Any]]:
|
|
169
|
+
"""
|
|
170
|
+
Returns a mapping of field name -> callable resolver that matches
|
|
171
|
+
the expected fastql_query_field or fastql_mutation_field decorators.
|
|
172
|
+
Raises if mismatched decorators are detected.
|
|
173
|
+
"""
|
|
174
|
+
resolvers: dict[str, Callable[..., Any]] = {}
|
|
175
|
+
|
|
176
|
+
for attr_name in dir(instance):
|
|
177
|
+
if attr_name.startswith("_"):
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
attr = getattr(instance, attr_name)
|
|
181
|
+
|
|
182
|
+
if isinstance(attr, StrawberryField):
|
|
183
|
+
base = attr.base_resolver
|
|
184
|
+
if isinstance(base, StrawberryResolver):
|
|
185
|
+
resolver = base.wrapped_func.__get__(instance, cls)
|
|
186
|
+
else:
|
|
187
|
+
resolver = base
|
|
188
|
+
else:
|
|
189
|
+
resolver = attr
|
|
190
|
+
|
|
191
|
+
if not callable(resolver):
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# Validate that the method is marked as a mutation or query
|
|
195
|
+
is_query_field = hasattr(resolver, "_fastql_query_field")
|
|
196
|
+
is_mutation_field = hasattr(resolver, "_fastql_mutation_field")
|
|
197
|
+
|
|
198
|
+
if expected_decorator_flag == "_fastql_is_query" and is_mutation_field:
|
|
199
|
+
raise TypeError(
|
|
200
|
+
f"{cls.__name__}.{attr_name} is marked as a mutation field inside a query class"
|
|
201
|
+
)
|
|
202
|
+
if expected_decorator_flag == "_fastql_is_mutation" and is_query_field:
|
|
203
|
+
raise TypeError(
|
|
204
|
+
f"{cls.__name__}.{attr_name} is marked as a query field inside a mutation class"
|
|
205
|
+
)
|
|
206
|
+
# Validate that we have an acceptable return/output type
|
|
207
|
+
return_type = hasattr(resolver, "_fastql_return_type")
|
|
208
|
+
if return_type is None:
|
|
209
|
+
raise TypeError(
|
|
210
|
+
f"{cls.__name__} is will be included in fastql schema but has no return type"
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
resolvers[attr_name] = resolver
|
|
214
|
+
|
|
215
|
+
if not resolvers:
|
|
216
|
+
kind = "query" if expected_decorator_flag == "_fastql_is_query" else "mutation"
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"{cls.__name__} is marked as a {kind} class but defines no valid {kind} fields."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return resolvers
|
|
222
|
+
|
|
223
|
+
def _include_mutation(self, mutation_instance: Any) -> None:
|
|
224
|
+
self._mutations.append(mutation_instance)
|
|
225
|
+
|
|
226
|
+
def _include_query(self, query_instance: Any) -> None:
|
|
227
|
+
self._queries.append(query_instance)
|
|
228
|
+
|
|
229
|
+
def _register_type_error(self, error_type: Any) -> None:
|
|
230
|
+
if error_type in self._types_error:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
if not hasattr(error_type, "_fastql_type_error"):
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
self._logger.info(
|
|
237
|
+
"Registering FastQL error type", extra={"error_type": error_type.__name__}
|
|
238
|
+
)
|
|
239
|
+
self._types_error.append(error_type)
|
|
240
|
+
|
|
241
|
+
def _register_type_id(self, id_type: Any) -> None:
|
|
242
|
+
if id_type in self._types_id:
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
if not hasattr(id_type, "_fastql_type_id"):
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
self._logger.info("Registering FastQL id type", extra={"id_type": id_type.__name__})
|
|
249
|
+
self._types_id.append(id_type)
|
|
250
|
+
|
|
251
|
+
def _register_type_input(self, input_type: Any) -> None:
|
|
252
|
+
if input_type in self._types_input:
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
# Special case: Register any typed id on the
|
|
256
|
+
# input type.
|
|
257
|
+
if hasattr(input_type, "_fastql_type_id"):
|
|
258
|
+
self._register_type_id(input_type)
|
|
259
|
+
return
|
|
260
|
+
|
|
261
|
+
if not hasattr(input_type, "_fastql_type_input"):
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
self._logger.info(
|
|
265
|
+
"Registering FastQL intput type", extra={"input_type": input_type.__name__}
|
|
266
|
+
)
|
|
267
|
+
self._types_input.append(input_type)
|
|
268
|
+
|
|
269
|
+
# Recursively check field types
|
|
270
|
+
for field_type in getattr(input_type, "__annotations__", {}).values():
|
|
271
|
+
self._register_type_input(field_type)
|
|
272
|
+
|
|
273
|
+
def _register_type_output(self, output_type: Any) -> None:
|
|
274
|
+
output_type = extract_concrete_type(output_type)
|
|
275
|
+
if output_type in self._types_output:
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
# Special case: Register any typed id on the
|
|
279
|
+
# output type.
|
|
280
|
+
if hasattr(output_type, "_fastql_type_id"):
|
|
281
|
+
self._register_type_id(output_type)
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
if not hasattr(output_type, "_fastql_type_output"):
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
self._logger.info(
|
|
288
|
+
"Registering FastQL output type", extra={"output_type": output_type.__name__}
|
|
289
|
+
)
|
|
290
|
+
self._types_output.append(output_type)
|
|
291
|
+
|
|
292
|
+
# Recursively check field types
|
|
293
|
+
for field_type in getattr(output_type, "__annotations__", {}).values():
|
|
294
|
+
self._register_type_output(field_type)
|
|
295
|
+
|
|
296
|
+
def extract_mutation_field_metadata(
|
|
297
|
+
self,
|
|
298
|
+
instance: Any,
|
|
299
|
+
) -> dict[str, Any] | None:
|
|
300
|
+
"""
|
|
301
|
+
Returns a mapping of field name -> field resolver
|
|
302
|
+
"""
|
|
303
|
+
cls = type(instance)
|
|
304
|
+
if not getattr(cls, "_fastql_is_mutation", False):
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
resolvers = self._extract_fastql_resolvers(
|
|
308
|
+
instance, cls, expected_decorator_flag="_fastql_is_mutation"
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
mutation_metadata: dict[str, Any] = {}
|
|
312
|
+
self._attach_fields_to_namespace(resolvers=resolvers, namespace=mutation_metadata)
|
|
313
|
+
|
|
314
|
+
return mutation_metadata
|
|
315
|
+
|
|
316
|
+
def extract_query_field_metadata(
|
|
317
|
+
self,
|
|
318
|
+
instance: Any,
|
|
319
|
+
) -> dict[str, Any] | None:
|
|
320
|
+
"""
|
|
321
|
+
Returns a mapping of field name -> field resolver
|
|
322
|
+
"""
|
|
323
|
+
cls = type(instance)
|
|
324
|
+
if not getattr(cls, "_fastql_is_query", False):
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
resolvers = self._extract_fastql_resolvers(
|
|
328
|
+
instance, cls, expected_decorator_flag="_fastql_is_query"
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
query_metadata: dict[str, Any] = {}
|
|
332
|
+
self._attach_fields_to_namespace(resolvers=resolvers, namespace=query_metadata)
|
|
333
|
+
|
|
334
|
+
return query_metadata
|
|
335
|
+
|
|
336
|
+
def include_in_schema(self, instance: Any) -> None:
|
|
337
|
+
cls = type(instance)
|
|
338
|
+
if getattr(cls, "_fastql_is_query", False):
|
|
339
|
+
self._include_query(instance)
|
|
340
|
+
elif getattr(cls, "_fastql_is_mutation", False):
|
|
341
|
+
self._include_mutation(instance)
|
|
342
|
+
else:
|
|
343
|
+
self._logger.critical(
|
|
344
|
+
"Query or mutation is missing fastql decorator. Please add @fastql_query() or "
|
|
345
|
+
+ "@fastql_mutation() to the class.",
|
|
346
|
+
stack_info=True,
|
|
347
|
+
extra={"cls": cls.__name__},
|
|
348
|
+
)
|
|
349
|
+
raise TypeError(
|
|
350
|
+
"Query or mutation is missing fastql decorator. Please add @fastql_query() or "
|
|
351
|
+
+ f"@fastql_mutation() to the class: {cls.__name__}"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
##### ##### ##### Properties and Runtime ##### ##### #####
|
|
355
|
+
|
|
356
|
+
def create_router(
|
|
357
|
+
self,
|
|
358
|
+
context_getter: Callable[..., Any | None | Awaitable[Any | None]],
|
|
359
|
+
graphiql: bool,
|
|
360
|
+
) -> GraphQLRouter:
|
|
361
|
+
graphql_ide: GraphQL_IDE | None = "graphiql" if graphiql else None
|
|
362
|
+
return GraphQLRouter(
|
|
363
|
+
schema=self.schema,
|
|
364
|
+
path="/graphql",
|
|
365
|
+
context_getter=context_getter,
|
|
366
|
+
graphql_ide=graphql_ide,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def all_types(self) -> list[type]:
|
|
371
|
+
return sorted(
|
|
372
|
+
set(self._types_error + self._types_id + self._types_input + self._types_output),
|
|
373
|
+
key=lambda cls: cls.__name__,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
@property
|
|
377
|
+
def all_types_map(self) -> dict[str, type]:
|
|
378
|
+
return {cls.__name__: cls for cls in self.all_types}
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def mutations_raw(self) -> list[Any]:
|
|
382
|
+
return self._mutations
|
|
383
|
+
|
|
384
|
+
@property
|
|
385
|
+
def mutations_map(self) -> dict[str, type]:
|
|
386
|
+
m_map: dict[str, type] = {}
|
|
387
|
+
for m in self._mutations:
|
|
388
|
+
cls = type(m)
|
|
389
|
+
m_map[cls.__name__] = m
|
|
390
|
+
|
|
391
|
+
return m_map
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def queries_raw(self) -> list[Any]:
|
|
395
|
+
return self._queries
|
|
396
|
+
|
|
397
|
+
@property
|
|
398
|
+
def queries_map(self) -> dict[str, type]:
|
|
399
|
+
q_map: dict[str, type] = {}
|
|
400
|
+
for q in self._queries:
|
|
401
|
+
cls = type(q)
|
|
402
|
+
q_map[cls.__name__] = q
|
|
403
|
+
|
|
404
|
+
return q_map
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def schema(self) -> strawberry.Schema:
|
|
408
|
+
if self._graphql_schema is None:
|
|
409
|
+
self._graphql_schema = strawberry.Schema(
|
|
410
|
+
query=self._compose_queries(),
|
|
411
|
+
mutation=self._compose_mutations(),
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
return self._graphql_schema
|
|
415
|
+
|
|
416
|
+
@property
|
|
417
|
+
def types_error_metadata(self) -> list[tuple[str, list[str]]]:
|
|
418
|
+
"""
|
|
419
|
+
Returns a list of (error_type_name, field_names) for each error type.
|
|
420
|
+
"""
|
|
421
|
+
result: list[tuple[str, list[str]]] = []
|
|
422
|
+
for cls in self._types_error:
|
|
423
|
+
try:
|
|
424
|
+
annotations = get_type_hints(cls)
|
|
425
|
+
except Exception:
|
|
426
|
+
annotations = getattr(cls, "__annotations__", {})
|
|
427
|
+
result.append((cls.__name__, list(annotations.keys())))
|
|
428
|
+
|
|
429
|
+
result.sort(key=lambda item: item[0])
|
|
430
|
+
return result
|
|
431
|
+
|
|
432
|
+
@property
|
|
433
|
+
def types_error_raw(self) -> list[type]:
|
|
434
|
+
return self._types_error
|
|
435
|
+
|
|
436
|
+
@property
|
|
437
|
+
def types_id_raw(self) -> list[type]:
|
|
438
|
+
return self._types_id
|
|
439
|
+
|
|
440
|
+
@property
|
|
441
|
+
def types_id_metadata(self) -> list[str]:
|
|
442
|
+
return sorted(cls.__name__ for cls in self._types_id)
|
|
443
|
+
|
|
444
|
+
@property
|
|
445
|
+
def types_input_metadata(self) -> list[tuple[str, list[str]]]:
|
|
446
|
+
result = []
|
|
447
|
+
for cls in self._types_input:
|
|
448
|
+
try:
|
|
449
|
+
annotations = get_type_hints(cls)
|
|
450
|
+
except Exception:
|
|
451
|
+
annotations = getattr(cls, "__annotations__", {})
|
|
452
|
+
result.append((cls.__name__, list(annotations.keys())))
|
|
453
|
+
result.sort(key=lambda item: item[0])
|
|
454
|
+
return result
|
|
455
|
+
|
|
456
|
+
@property
|
|
457
|
+
def types_input_raw(self) -> list[type]:
|
|
458
|
+
return self._types_input
|
|
459
|
+
|
|
460
|
+
@property
|
|
461
|
+
def types_output_metadata(self) -> list[tuple[str, list[str]]]:
|
|
462
|
+
"""
|
|
463
|
+
Returns a list of (type_name, field_names) for each output type.
|
|
464
|
+
Useful for generating GraphQL fragments.
|
|
465
|
+
"""
|
|
466
|
+
result: list[tuple[str, list[str]]] = []
|
|
467
|
+
for cls in self._types_output:
|
|
468
|
+
try:
|
|
469
|
+
annotations = get_type_hints(cls)
|
|
470
|
+
except Exception:
|
|
471
|
+
annotations = getattr(cls, "__annotations__", {}) # fallback
|
|
472
|
+
result.append((cls.__name__, list(annotations.keys())))
|
|
473
|
+
|
|
474
|
+
result.sort(key=lambda item: item[0]) # sort by type name
|
|
475
|
+
return result
|
|
476
|
+
|
|
477
|
+
@property
|
|
478
|
+
def types_output_raw(self) -> list[type]:
|
|
479
|
+
return self._types_output
|
|
480
|
+
|
|
481
|
+
##### ##### ##### Codegen ##### ##### #####
|
|
482
|
+
|
|
483
|
+
def collect_and_print_fragments(
|
|
484
|
+
self,
|
|
485
|
+
typename: str,
|
|
486
|
+
visited: set[str],
|
|
487
|
+
fragments: dict[str, str],
|
|
488
|
+
) -> None:
|
|
489
|
+
if typename in visited:
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
visited.add(typename)
|
|
493
|
+
cls = self.all_types_map.get(typename)
|
|
494
|
+
if not cls:
|
|
495
|
+
return
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
annotations = get_type_hints(cls)
|
|
499
|
+
except Exception:
|
|
500
|
+
annotations = getattr(cls, "__annotations__", {})
|
|
501
|
+
|
|
502
|
+
lines = [f"fragment {typename} on {typename} {{"]
|
|
503
|
+
|
|
504
|
+
for field_name, field_type in annotations.items():
|
|
505
|
+
field_name_camel = inflection.camelize(field_name, uppercase_first_letter=False)
|
|
506
|
+
nested_type = None
|
|
507
|
+
|
|
508
|
+
origin = get_origin(field_type)
|
|
509
|
+
args = get_args(field_type)
|
|
510
|
+
|
|
511
|
+
# CASE: Optional and Union[X, Y, None] and X | Y
|
|
512
|
+
if origin in (Optional, Union, UnionType):
|
|
513
|
+
for arg in args:
|
|
514
|
+
arg_name = getattr(arg, "__name__", None)
|
|
515
|
+
if arg_name and arg_name in self.all_types_map:
|
|
516
|
+
nested_type = arg_name
|
|
517
|
+
break
|
|
518
|
+
# CASE: List[X] and list[X]
|
|
519
|
+
elif origin in (list, List): # noqa: UP006
|
|
520
|
+
(inner_type,) = args
|
|
521
|
+
inner_type_name = getattr(inner_type, "__name__", None)
|
|
522
|
+
if inner_type_name and inner_type_name in self.all_types_map:
|
|
523
|
+
nested_type = inner_type_name
|
|
524
|
+
# Recurse into the list's inner type
|
|
525
|
+
self.collect_and_print_fragments(nested_type, visited, fragments)
|
|
526
|
+
lines.append(f" {field_name_camel} {{")
|
|
527
|
+
lines.append(f" ...{nested_type}")
|
|
528
|
+
lines.append(" }")
|
|
529
|
+
continue
|
|
530
|
+
elif hasattr(field_type, "__name__") and field_type.__name__ in self.all_types_map:
|
|
531
|
+
nested_type = field_type.__name__
|
|
532
|
+
|
|
533
|
+
if nested_type:
|
|
534
|
+
# Recurse first so nested fragments are available
|
|
535
|
+
self.collect_and_print_fragments(nested_type, visited, fragments)
|
|
536
|
+
lines.append(f" {field_name_camel} {{")
|
|
537
|
+
lines.append(f" ...{nested_type}")
|
|
538
|
+
lines.append(" }")
|
|
539
|
+
else:
|
|
540
|
+
lines.append(f" {field_name_camel}")
|
|
541
|
+
|
|
542
|
+
lines.append("}")
|
|
543
|
+
fragments[typename] = "\n".join(lines)
|
|
544
|
+
|
|
545
|
+
def collect_and_print_mutations(
|
|
546
|
+
self,
|
|
547
|
+
mutation_class_name: str,
|
|
548
|
+
visited: set[str],
|
|
549
|
+
mutations: dict[str, str],
|
|
550
|
+
) -> None:
|
|
551
|
+
if mutation_class_name in visited:
|
|
552
|
+
return
|
|
553
|
+
|
|
554
|
+
visited.add(mutation_class_name)
|
|
555
|
+
|
|
556
|
+
m = self.mutations_map[mutation_class_name]
|
|
557
|
+
if m is None:
|
|
558
|
+
return
|
|
559
|
+
|
|
560
|
+
schema = self.schema
|
|
561
|
+
m_field_metadata = self.extract_mutation_field_metadata(m)
|
|
562
|
+
if m_field_metadata is None:
|
|
563
|
+
# This should not happen
|
|
564
|
+
# Very edge case in which we pass in a
|
|
565
|
+
# non-mutation instance
|
|
566
|
+
return
|
|
567
|
+
|
|
568
|
+
for _, m_metadata in m_field_metadata.items():
|
|
569
|
+
if isinstance(m_metadata, StrawberryField):
|
|
570
|
+
m_name = f"{m_metadata.graphql_name}Mutation"
|
|
571
|
+
m_name = m_name[0].upper() + m_name[1:]
|
|
572
|
+
m_args = {
|
|
573
|
+
f"${arg.python_name}": schema.schema_converter.from_argument(arg)
|
|
574
|
+
for arg in m_metadata.arguments
|
|
575
|
+
}
|
|
576
|
+
lines = [
|
|
577
|
+
f"mutation {m_name}{print_args(m_args, schema=schema, extras=PrintExtras())} {{"
|
|
578
|
+
]
|
|
579
|
+
|
|
580
|
+
gql_name = m_metadata.graphql_name
|
|
581
|
+
gql_args = [
|
|
582
|
+
f"{m_arg_name.strip('$')}: {m_arg_name}" for m_arg_name, _ in m_args.items()
|
|
583
|
+
]
|
|
584
|
+
gql_args_str = f"({','.join(gql_args)})" if len(gql_args) > 0 else ""
|
|
585
|
+
lines.append(f" {gql_name}{gql_args_str} {{")
|
|
586
|
+
|
|
587
|
+
if isinstance(m_metadata.type, StrawberryUnion):
|
|
588
|
+
for gql_type_annotation in m_metadata.type.type_annotations:
|
|
589
|
+
gql_result_type = gql_type_annotation.evaluate()
|
|
590
|
+
gql_result_type_name = gql_result_type.__name__
|
|
591
|
+
lines.append(f" ... on {gql_result_type_name} {{")
|
|
592
|
+
lines.append(f" ... {gql_result_type_name}")
|
|
593
|
+
lines.append(" }")
|
|
594
|
+
else:
|
|
595
|
+
gql_result_type = m_metadata.type # type: ignore[assignment]
|
|
596
|
+
gql_result_type_name = gql_result_type.__name__
|
|
597
|
+
lines.append(f" ... on {gql_result_type_name} {{")
|
|
598
|
+
lines.append(f" ... {gql_result_type_name}")
|
|
599
|
+
lines.append(" }")
|
|
600
|
+
|
|
601
|
+
lines.append(" __typename")
|
|
602
|
+
lines.append(" }")
|
|
603
|
+
lines.append("}")
|
|
604
|
+
|
|
605
|
+
mutations[m_name] = "\n".join(lines)
|
|
606
|
+
|
|
607
|
+
def collect_and_print_queries(
|
|
608
|
+
self,
|
|
609
|
+
query_class_name: str,
|
|
610
|
+
visited: set[str],
|
|
611
|
+
queries: dict[str, str],
|
|
612
|
+
) -> None:
|
|
613
|
+
if query_class_name in visited:
|
|
614
|
+
return
|
|
615
|
+
|
|
616
|
+
visited.add(query_class_name)
|
|
617
|
+
|
|
618
|
+
q = self.queries_map[query_class_name]
|
|
619
|
+
if q is None:
|
|
620
|
+
return
|
|
621
|
+
|
|
622
|
+
schema = self.schema
|
|
623
|
+
q_field_metadata = self.extract_query_field_metadata(q)
|
|
624
|
+
if q_field_metadata is None:
|
|
625
|
+
# This should not happen
|
|
626
|
+
# Very edge case in which we pass in a
|
|
627
|
+
# non-query instance
|
|
628
|
+
return
|
|
629
|
+
|
|
630
|
+
for _, q_metadata in q_field_metadata.items():
|
|
631
|
+
if isinstance(q_metadata, StrawberryField):
|
|
632
|
+
q_name = f"{q_metadata.graphql_name}Query"
|
|
633
|
+
q_name = q_name[0].upper() + q_name[1:]
|
|
634
|
+
q_args = {
|
|
635
|
+
f"${arg.python_name}": schema.schema_converter.from_argument(arg)
|
|
636
|
+
for arg in q_metadata.arguments
|
|
637
|
+
}
|
|
638
|
+
lines = [
|
|
639
|
+
f"query {q_name}{print_args(q_args, schema=schema, extras=PrintExtras())} {{"
|
|
640
|
+
]
|
|
641
|
+
|
|
642
|
+
gql_name = q_metadata.graphql_name
|
|
643
|
+
gql_args = [
|
|
644
|
+
f"{q_arg_name.strip('$')}: {q_arg_name}" for q_arg_name, _ in q_args.items()
|
|
645
|
+
]
|
|
646
|
+
gql_args_str = f"({','.join(gql_args)})" if len(gql_args) > 0 else ""
|
|
647
|
+
lines.append(f" {gql_name}{gql_args_str} {{")
|
|
648
|
+
|
|
649
|
+
if isinstance(q_metadata.type, StrawberryUnion):
|
|
650
|
+
for gql_type_annotation in q_metadata.type.type_annotations:
|
|
651
|
+
gql_result_type = gql_type_annotation.evaluate()
|
|
652
|
+
gql_result_type_name = gql_result_type.__name__
|
|
653
|
+
lines.append(f" ... on {gql_result_type_name} {{")
|
|
654
|
+
lines.append(f" ... {gql_result_type_name}")
|
|
655
|
+
lines.append(" }")
|
|
656
|
+
else:
|
|
657
|
+
gql_result_type = q_metadata.type # type: ignore[assignment]
|
|
658
|
+
gql_result_type_name = gql_result_type.__name__
|
|
659
|
+
lines.append(f" ... on {gql_result_type_name} {{")
|
|
660
|
+
lines.append(f" ... {gql_result_type_name}")
|
|
661
|
+
lines.append(" }")
|
|
662
|
+
|
|
663
|
+
lines.append(" __typename")
|
|
664
|
+
lines.append(" }")
|
|
665
|
+
lines.append("}")
|
|
666
|
+
|
|
667
|
+
queries[q_name] = "\n".join(lines)
|
|
668
|
+
|
|
669
|
+
def print_schema(self) -> str:
|
|
670
|
+
return print_schema(self.schema)
|
|
671
|
+
|
|
672
|
+
def write_graphql_file(
|
|
673
|
+
self, base_dir: str, file_name: str, file_content: str, file_header: str | None = None
|
|
674
|
+
) -> None:
|
|
675
|
+
graphql_path = Path(base_dir, file_name)
|
|
676
|
+
graphql_path.parent.mkdir(parents=True, exist_ok=True)
|
|
677
|
+
|
|
678
|
+
with open(graphql_path, "w") as f:
|
|
679
|
+
if file_header is not None:
|
|
680
|
+
f.write(file_header)
|
|
681
|
+
f.write(file_content)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from apppy.fastql.annotation.error import fastql_type_error # noqa: F401
|
|
2
|
+
from apppy.fastql.annotation.id import fastql_type_id # noqa: F401
|
|
3
|
+
from apppy.fastql.annotation.input import fastql_type_input # noqa: F401
|
|
4
|
+
from apppy.fastql.annotation.interface import fastql_type_interface # noqa: F401
|
|
5
|
+
from apppy.fastql.annotation.mutation import fastql_mutation, fastql_mutation_field # noqa: F401
|
|
6
|
+
from apppy.fastql.annotation.output import fastql_type_output # noqa: F401
|
|
7
|
+
from apppy.fastql.annotation.query import fastql_query, fastql_query_field # noqa: F401
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import strawberry
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fastql_type_error(cls: type[Any]):
|
|
7
|
+
"""
|
|
8
|
+
Custom field decorator that marks error types (i.e. response errors from APIs)
|
|
9
|
+
"""
|
|
10
|
+
cls._fastql_type_error = True # type: ignore[attr-defined]
|
|
11
|
+
return strawberry.type(cls)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def valid_fastql_type_error(cls: Any) -> bool:
|
|
15
|
+
return hasattr(cls, "_fastql_type_error")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import strawberry
|
|
2
|
+
|
|
3
|
+
from apppy.fastql.typed_id import TypedId
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fastql_type_id(cls: type[TypedId]):
|
|
7
|
+
"""
|
|
8
|
+
Decorator for TypedId subclasses that automatically registers them as GraphQL scalars.
|
|
9
|
+
"""
|
|
10
|
+
if not issubclass(cls, TypedId):
|
|
11
|
+
raise TypeError(f"{cls.__name__} must subclass TypedId to use @fastql_type_id")
|
|
12
|
+
|
|
13
|
+
cls._fastql_type_id = True # type: ignore[attr-defined]
|
|
14
|
+
|
|
15
|
+
return strawberry.scalar(
|
|
16
|
+
serialize=lambda value: str(value),
|
|
17
|
+
parse_value=lambda raw: cls.from_str(raw),
|
|
18
|
+
)(cls)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import strawberry
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fastql_type_input(cls: type[Any]):
|
|
7
|
+
"""
|
|
8
|
+
Custom field decorator that marks input types (i.e. request types in APIs)
|
|
9
|
+
"""
|
|
10
|
+
cls._fastql_type_input = True # type: ignore[attr-defined]
|
|
11
|
+
return strawberry.input(cls)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def valid_fastql_type_input(cls: Any) -> bool:
|
|
15
|
+
return hasattr(cls, "_fastql_type_input")
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import strawberry
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fastql_type_interface(cls: type[Any]):
|
|
7
|
+
"""
|
|
8
|
+
Decorator to wrap strawberry.interface
|
|
9
|
+
"""
|
|
10
|
+
cls._fastql_type_interface = True # type: ignore[attr-defined]
|
|
11
|
+
return strawberry.interface(cls)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def valid_fastql_type_interface(cls: type) -> bool:
|
|
15
|
+
return hasattr(cls, "_fastql_type_interface")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from collections.abc import Callable, Sequence
|
|
2
|
+
from typing import get_type_hints
|
|
3
|
+
|
|
4
|
+
from apppy.fastql.annotation.output import valid_fastql_type_output
|
|
5
|
+
from apppy.fastql.errors import GraphQLError
|
|
6
|
+
from apppy.fastql.permissions import GraphQLPermission
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fastql_mutation():
|
|
10
|
+
"""
|
|
11
|
+
Custom class decorator to mark mutation containers for FastQL schema composition.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def decorator(cls):
|
|
15
|
+
cls._fastql_is_mutation = True # type: ignore[attr-defined]
|
|
16
|
+
return cls
|
|
17
|
+
|
|
18
|
+
return decorator
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fastql_mutation_field(
|
|
22
|
+
*,
|
|
23
|
+
error_types: Sequence[type[GraphQLError]] = (),
|
|
24
|
+
auth_check: type[GraphQLPermission] | None = None,
|
|
25
|
+
iam_check: GraphQLPermission | None = None,
|
|
26
|
+
skip_permission_checks: bool = False,
|
|
27
|
+
):
|
|
28
|
+
"""
|
|
29
|
+
Custom field decorator that marks mutation fields for inclusion in FastQL.
|
|
30
|
+
|
|
31
|
+
Args
|
|
32
|
+
error_types: Possible error types raised by the query
|
|
33
|
+
auth_check: GraphQLPermission authentication class guarding this field
|
|
34
|
+
iam_check: GraphQLPermission authorization guarding this field
|
|
35
|
+
skip_permission_checks: Flag indicating that no authentication nor
|
|
36
|
+
authorization permissions will be checked for
|
|
37
|
+
this field (i.e. it is an open API)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def decorator(resolver: Callable):
|
|
41
|
+
all_error_types: set[type[GraphQLError]] = set()
|
|
42
|
+
all_error_types.update(error_types)
|
|
43
|
+
all_permission_instances: set[GraphQLPermission] = set()
|
|
44
|
+
if not skip_permission_checks:
|
|
45
|
+
# Process auth_check
|
|
46
|
+
auth_check_cls = auth_check
|
|
47
|
+
if auth_check_cls is None:
|
|
48
|
+
raise ValueError("No auth_check permission provided")
|
|
49
|
+
|
|
50
|
+
all_error_types.add(auth_check_cls.graphql_client_error_class)
|
|
51
|
+
all_error_types.add(auth_check_cls.graphql_server_error_class)
|
|
52
|
+
all_permission_instances.add(auth_check_cls())
|
|
53
|
+
# Process iam_check
|
|
54
|
+
if iam_check is not None:
|
|
55
|
+
iam_check_cls = type(iam_check)
|
|
56
|
+
all_error_types.add(iam_check_cls.graphql_client_error_class)
|
|
57
|
+
all_error_types.add(iam_check_cls.graphql_server_error_class)
|
|
58
|
+
all_permission_instances.add(iam_check)
|
|
59
|
+
|
|
60
|
+
# Sort all error types for stable code generation
|
|
61
|
+
all_error_types_sorted: list[type[GraphQLError]] = sorted(
|
|
62
|
+
all_error_types, key=lambda t: t.__name__
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return_type = get_type_hints(resolver).get("return")
|
|
66
|
+
resolver_name = getattr(resolver, "__name__", "<unknown>")
|
|
67
|
+
|
|
68
|
+
if return_type is None:
|
|
69
|
+
raise TypeError(f"Missing return type hint for resolver: {resolver_name}")
|
|
70
|
+
|
|
71
|
+
if not valid_fastql_type_output(return_type):
|
|
72
|
+
raise TypeError(
|
|
73
|
+
f"Return type of {resolver_name} must be a valid @fastql_type_output type."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
resolver._fastql_mutation_field = True # type: ignore[attr-defined]
|
|
77
|
+
resolver._fastql_return_type = return_type # type: ignore[attr-defined]
|
|
78
|
+
resolver._fastql_error_types = tuple(all_error_types_sorted) # type: ignore[attr-defined]
|
|
79
|
+
resolver._fastql_permission_instances = tuple(all_permission_instances) # type: ignore[attr-defined]
|
|
80
|
+
resolver._skip_permission_checks = skip_permission_checks # type: ignore[attr-defined]
|
|
81
|
+
|
|
82
|
+
return resolver
|
|
83
|
+
|
|
84
|
+
return decorator
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from types import UnionType
|
|
2
|
+
from typing import Any, Union, get_args, get_origin
|
|
3
|
+
|
|
4
|
+
import strawberry
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def extract_concrete_type(typ: Any) -> Any:
|
|
8
|
+
"""
|
|
9
|
+
Given something like Optional[MyType] or List[MyType], return MyType.
|
|
10
|
+
"""
|
|
11
|
+
origin = get_origin(typ)
|
|
12
|
+
if origin in {Union, UnionType, list, tuple}:
|
|
13
|
+
args = get_args(typ)
|
|
14
|
+
for arg in args:
|
|
15
|
+
if arg is not type(None): # skip NoneType
|
|
16
|
+
return extract_concrete_type(arg)
|
|
17
|
+
return typ
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def fastql_type_output(cls: type[Any]):
|
|
21
|
+
"""
|
|
22
|
+
Custom field decorator that marks output types (i.e. response types from APIs)
|
|
23
|
+
"""
|
|
24
|
+
cls._fastql_type_output = True # type: ignore[attr-defined]
|
|
25
|
+
return strawberry.type(cls)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def valid_fastql_type_output(cls: Any) -> bool:
|
|
29
|
+
cls = extract_concrete_type(cls)
|
|
30
|
+
return hasattr(cls, "_fastql_type_output")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from typing import get_type_hints
|
|
5
|
+
|
|
6
|
+
from apppy.fastql.annotation.output import valid_fastql_type_output
|
|
7
|
+
from apppy.fastql.errors import GraphQLError
|
|
8
|
+
from apppy.fastql.permissions import GraphQLPermission
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fastql_query():
|
|
12
|
+
"""
|
|
13
|
+
Custom class decorator that marks query classes for inclusion in FastQL
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def decorator(cls):
|
|
17
|
+
cls._fastql_is_query = True # type: ignore[attr-defined]
|
|
18
|
+
return cls
|
|
19
|
+
|
|
20
|
+
return decorator
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def fastql_query_field(
|
|
24
|
+
*,
|
|
25
|
+
error_types: Sequence[type[GraphQLError]] = (),
|
|
26
|
+
auth_check: type[GraphQLPermission] | None = None,
|
|
27
|
+
iam_check: GraphQLPermission | None = None,
|
|
28
|
+
skip_permission_checks: bool = False,
|
|
29
|
+
):
|
|
30
|
+
"""
|
|
31
|
+
Custom field decorator that marks query fields for inclusion in FastQL.
|
|
32
|
+
|
|
33
|
+
Args
|
|
34
|
+
error_types: Possible error types raised by the query
|
|
35
|
+
auth_check: GraphQLPermission authentication class guarding this field
|
|
36
|
+
iam_check: GraphQLPermission authorization guarding this field
|
|
37
|
+
skip_permission_checks: Flag indicating that no authentication nor
|
|
38
|
+
authorization permissions will be checked for
|
|
39
|
+
this field (i.e. it is an open API)
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def decorator(resolver: Callable):
|
|
43
|
+
all_error_types: set[type[GraphQLError]] = set()
|
|
44
|
+
all_error_types.update(error_types)
|
|
45
|
+
all_permission_instances: set[GraphQLPermission] = set()
|
|
46
|
+
if not skip_permission_checks:
|
|
47
|
+
# Process auth_check
|
|
48
|
+
auth_check_cls = auth_check
|
|
49
|
+
if auth_check_cls is None:
|
|
50
|
+
raise ValueError("No auth_check permission provided")
|
|
51
|
+
|
|
52
|
+
all_error_types.add(auth_check_cls.graphql_client_error_class)
|
|
53
|
+
all_error_types.add(auth_check_cls.graphql_server_error_class)
|
|
54
|
+
all_permission_instances.add(auth_check_cls())
|
|
55
|
+
# Process iam_check
|
|
56
|
+
if iam_check is not None:
|
|
57
|
+
iam_check_cls = type(iam_check)
|
|
58
|
+
all_error_types.add(iam_check_cls.graphql_client_error_class)
|
|
59
|
+
all_error_types.add(iam_check_cls.graphql_server_error_class)
|
|
60
|
+
all_permission_instances.add(iam_check)
|
|
61
|
+
|
|
62
|
+
# Sort all error types for stable code generation
|
|
63
|
+
all_error_types_sorted: list[type[GraphQLError]] = sorted(
|
|
64
|
+
all_error_types, key=lambda t: t.__name__
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return_type = get_type_hints(resolver).get("return")
|
|
68
|
+
resolver_name = getattr(resolver, "__name__", "<unknown>")
|
|
69
|
+
|
|
70
|
+
if return_type is None:
|
|
71
|
+
raise TypeError(f"Missing return type hint for resolver: {resolver_name}")
|
|
72
|
+
|
|
73
|
+
if not valid_fastql_type_output(return_type):
|
|
74
|
+
raise TypeError(
|
|
75
|
+
f"Return type of {resolver_name} must be a valid @fastql_type_output type." # noqa: E501
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
resolver._fastql_query_field = True # type: ignore[attr-defined]
|
|
79
|
+
resolver._fastql_return_type = return_type # type: ignore[attr-defined]
|
|
80
|
+
resolver._fastql_error_types = tuple(all_error_types_sorted) # type: ignore[attr-defined]
|
|
81
|
+
resolver._fastql_permission_instances = tuple(all_permission_instances) # type: ignore[attr-defined]
|
|
82
|
+
resolver._skip_permission_checks = skip_permission_checks # type: ignore[attr-defined]
|
|
83
|
+
|
|
84
|
+
return resolver
|
|
85
|
+
|
|
86
|
+
return decorator
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from apppy.fastql.annotation.error import fastql_type_error
|
|
2
|
+
from apppy.fastql.annotation.interface import fastql_type_interface
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# NOTE: Do not use GraphQLError directly
|
|
6
|
+
# instead use GraphQLClientError or GraphQLServerError
|
|
7
|
+
@fastql_type_interface
|
|
8
|
+
class GraphQLError(BaseException):
|
|
9
|
+
"""Generic base class for any error raised in a GraphQL API"""
|
|
10
|
+
|
|
11
|
+
code: str
|
|
12
|
+
|
|
13
|
+
def __init__(self, code: str):
|
|
14
|
+
self.code: str = code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@fastql_type_interface
|
|
18
|
+
class GraphQLClientError(GraphQLError):
|
|
19
|
+
"""Base class for any GraphQL error raised related to bad client input"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, code: str = "generic_client_error"):
|
|
22
|
+
super().__init__(code)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@fastql_type_interface
|
|
26
|
+
class GraphQLServerError(GraphQLError):
|
|
27
|
+
"""Base class for any GraphQL error raised related to internal server processing"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, code: str = "generic_server_error"):
|
|
30
|
+
super().__init__(code)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@fastql_type_error
|
|
34
|
+
class TypedIdInvalidPrefixError(GraphQLClientError):
|
|
35
|
+
"""Raised when a TypedId encounters an invalid prefix"""
|
|
36
|
+
|
|
37
|
+
id: str
|
|
38
|
+
id_type: str
|
|
39
|
+
|
|
40
|
+
def __init__(self, id: str, id_type: str):
|
|
41
|
+
super().__init__("typed_id_invalid_prefix")
|
|
42
|
+
self.id = id
|
|
43
|
+
self.id_type = id_type
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import abc
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from strawberry.permission import BasePermission
|
|
5
|
+
|
|
6
|
+
from apppy.fastql.errors import GraphQLClientError, GraphQLServerError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GraphQLPermission(BasePermission):
|
|
10
|
+
"""
|
|
11
|
+
A generic base class which represents a graphql permission.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
# In GraphQL, there are strongly typed errors returned.
|
|
15
|
+
# Here, we allow an permission class to declare the GraphQL
|
|
16
|
+
# errors that are to be returned on either a server-side error
|
|
17
|
+
# or a client-side error
|
|
18
|
+
graphql_client_error_class: type[GraphQLClientError]
|
|
19
|
+
graphql_server_error_class: type[GraphQLServerError]
|
|
20
|
+
|
|
21
|
+
def on_unauthorized(self) -> None:
|
|
22
|
+
error = self.graphql_client_error_class(self.graphql_client_error_args()) # type: ignore[arg-type]
|
|
23
|
+
raise error
|
|
24
|
+
|
|
25
|
+
@abc.abstractmethod
|
|
26
|
+
def graphql_client_error_args(self) -> tuple[Any, ...]:
|
|
27
|
+
pass
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
from pydantic import Field
|
|
5
|
+
from sqids import Sqids
|
|
6
|
+
|
|
7
|
+
from apppy.env import EnvSettings
|
|
8
|
+
from apppy.fastql.annotation.interface import fastql_type_interface
|
|
9
|
+
from apppy.fastql.errors import TypedIdInvalidPrefixError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TypedIdEncoderSettings(EnvSettings):
|
|
13
|
+
alphabet: str = Field(alias="APP_GENERIC_TYPED_ID_ENCODER_ALPHABET", exclude=True)
|
|
14
|
+
min_length: int = Field(alias="APP_GENERIC_TYPED_ID_ENCODER_MIN_LENGTH", default=10)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TypedIdEncoder:
|
|
18
|
+
"""
|
|
19
|
+
Service to encode ids into strings based on a static
|
|
20
|
+
alphabet. This allows the system to ofuscate the integer
|
|
21
|
+
values to outside parties (e.g. database primary keys)
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
# NOTE: We use a global instance (i.e. static singleton) for
|
|
25
|
+
# TypedIdEncoder because we would like it to be used by all TypedId
|
|
26
|
+
# instances which will not be instantiated via the app container. So
|
|
27
|
+
# instead we'll have this static reference that they are able to use.
|
|
28
|
+
_global_instance: Optional["TypedIdEncoder"] = None
|
|
29
|
+
|
|
30
|
+
def __init__(self, settings: TypedIdEncoderSettings) -> None:
|
|
31
|
+
self._settings = settings
|
|
32
|
+
self._encoder = Sqids(alphabet=settings.alphabet, min_length=settings.min_length)
|
|
33
|
+
|
|
34
|
+
##### ##### ##### Integers ##### ##### #####
|
|
35
|
+
# Used to encode and decode integers. Which is
|
|
36
|
+
# useful for items like database primary keys
|
|
37
|
+
|
|
38
|
+
def encode_int(self, value: int) -> str:
|
|
39
|
+
return self._encoder.encode([value])
|
|
40
|
+
|
|
41
|
+
def decode_int(self, value: str) -> int:
|
|
42
|
+
return self._encoder.decode(value)[0]
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def get_global(cls) -> "TypedIdEncoder":
|
|
46
|
+
if cls._global_instance is None:
|
|
47
|
+
raise RuntimeError("TypedIdEncoder has not been initialized.")
|
|
48
|
+
return cls._global_instance
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def set_global(cls, instance: "TypedIdEncoder") -> None:
|
|
52
|
+
if cls._global_instance is None:
|
|
53
|
+
cls._global_instance = instance
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@fastql_type_interface
|
|
57
|
+
class TypedId(ABC):
|
|
58
|
+
"""
|
|
59
|
+
Base class for all typed ids. A typed id has a prefix
|
|
60
|
+
which signals it's type and an encoded value which
|
|
61
|
+
can be shared externally (i.e. without security concerns).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, encoded_int: str) -> None:
|
|
65
|
+
super().__init__()
|
|
66
|
+
self._encoded_int = encoded_int
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
@abstractmethod
|
|
70
|
+
def prefix(self) -> str:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def number(self) -> int:
|
|
75
|
+
return TypedIdEncoder.get_global().decode_int(self._encoded_int)
|
|
76
|
+
|
|
77
|
+
def __str__(self) -> str:
|
|
78
|
+
return f"{self.prefix}_{self._encoded_int}"
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_number(cls, id: int):
|
|
82
|
+
return cls(TypedIdEncoder.get_global().encode_int(id))
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_str(cls, id: str):
|
|
86
|
+
prefix = cls._get_prefix()
|
|
87
|
+
if not id.startswith(f"{prefix}_"):
|
|
88
|
+
raise TypedIdInvalidPrefixError(id=id, id_type=cls.__name__)
|
|
89
|
+
|
|
90
|
+
encoded_int = id[len(f"{prefix}_") :]
|
|
91
|
+
return cls(encoded_int)
|
|
92
|
+
|
|
93
|
+
@classmethod
|
|
94
|
+
def is_valid(cls, id: str):
|
|
95
|
+
prefix = cls._get_prefix()
|
|
96
|
+
return id.startswith(f"{prefix}_")
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def _get_prefix(cls):
|
|
100
|
+
instance = cls.__new__(cls)
|
|
101
|
+
return instance.prefix
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from apppy.env import DictEnv, Env
|
|
4
|
+
from apppy.fastql.errors import TypedIdInvalidPrefixError
|
|
5
|
+
from apppy.fastql.typed_id import TypedId, TypedIdEncoder, TypedIdEncoderSettings
|
|
6
|
+
|
|
7
|
+
_typed_id_encoder_env_test: Env = DictEnv(
|
|
8
|
+
name="int_encoder_test",
|
|
9
|
+
d={
|
|
10
|
+
"APP_GENERIC_TYPED_ID_ENCODER_ALPHABET": "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" # noqa: E501
|
|
11
|
+
},
|
|
12
|
+
)
|
|
13
|
+
_typed_id_encoder_settings_test: TypedIdEncoderSettings = TypedIdEncoderSettings( # type: ignore[misc]
|
|
14
|
+
_typed_id_encoder_env_test # type: ignore[arg-type]
|
|
15
|
+
)
|
|
16
|
+
_typed_id_encoder_test: TypedIdEncoder = TypedIdEncoder(_typed_id_encoder_settings_test)
|
|
17
|
+
TypedIdEncoder.set_global(_typed_id_encoder_test)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_int_encoder():
|
|
21
|
+
encoded_int = _typed_id_encoder_test.encode_int(5)
|
|
22
|
+
assert encoded_int == "tGj3JHachG"
|
|
23
|
+
|
|
24
|
+
decoded_int = _typed_id_encoder_test.decode_int("tGj3JHachG")
|
|
25
|
+
assert decoded_int == 5
|
|
26
|
+
|
|
27
|
+
encoded_int = _typed_id_encoder_test.encode_int(9_999_999_999)
|
|
28
|
+
assert encoded_int == "Tnega83VLI"
|
|
29
|
+
|
|
30
|
+
decoded_int = _typed_id_encoder_test.decode_int("Tnega83VLI")
|
|
31
|
+
assert decoded_int == 9_999_999_999
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class TypedTestId(TypedId):
|
|
35
|
+
@property
|
|
36
|
+
def prefix(self) -> str:
|
|
37
|
+
return "test"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_id_from_number():
|
|
41
|
+
id1 = TypedTestId.from_number(1)
|
|
42
|
+
assert str(id1) == "test_FrwMELkAmX"
|
|
43
|
+
assert id1.number == 1
|
|
44
|
+
|
|
45
|
+
id2 = TypedTestId.from_number(2)
|
|
46
|
+
assert str(id2) == "test_nLKnICbr1y"
|
|
47
|
+
assert id2.number == 2
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_id_from_str():
|
|
51
|
+
id1 = TypedTestId.from_str("test_FrwMELkAmX")
|
|
52
|
+
assert str(id1) == "test_FrwMELkAmX"
|
|
53
|
+
assert id1.number == 1
|
|
54
|
+
|
|
55
|
+
id2 = TypedTestId.from_str("test_nLKnICbr1y")
|
|
56
|
+
assert str(id2) == "test_nLKnICbr1y"
|
|
57
|
+
assert id2.number == 2
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_id_is_valid():
|
|
61
|
+
valid = TypedTestId.is_valid("test_FrwMELkAmX")
|
|
62
|
+
assert valid is True
|
|
63
|
+
|
|
64
|
+
invalid = TypedTestId.is_valid("invalid_FrwMELkAmX")
|
|
65
|
+
assert invalid is False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_id_invalid_prefix():
|
|
69
|
+
with pytest.raises(TypedIdInvalidPrefixError):
|
|
70
|
+
TypedTestId.from_str("invalid_FrwMELkAmX")
|