apppy-app 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.
@@ -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,21 @@
1
+ Metadata-Version: 2.4
2
+ Name: apppy-app
3
+ Version: 0.1.0
4
+ Summary: Application definitions and infrastructure 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-auth>=0.1.0
12
+ Requires-Dist: apppy-db>=0.1.0
13
+ Requires-Dist: apppy-env>=0.1.0
14
+ Requires-Dist: apppy-fastql>=0.1.0
15
+ Requires-Dist: apppy-logger>=0.1.0
16
+ Requires-Dist: click==8.2.1
17
+ Requires-Dist: dependency-injector==4.48.1
18
+ Requires-Dist: fastapi-lifespan-manager==0.1.4
19
+ Requires-Dist: fastapi==0.115.14
20
+ Requires-Dist: strawberry-graphql[fastapi]==0.275.5
21
+ Requires-Dist: uvicorn==0.35.0
File without changes
apppy_app-0.1.0/app.mk ADDED
@@ -0,0 +1,23 @@
1
+ ifndef APPPY_APP_MK_INCLUDED
2
+ APPPY_APP_MK_INCLUDED := 1
3
+ APP_PKG_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
4
+
5
+ .PHONY: app app-dev app/build app/clean app/install app/install-dev
6
+
7
+ app: app/clean app/install
8
+
9
+ app-dev: app/clean app/install-dev
10
+
11
+ app/build:
12
+ cd $(APP_PKG_DIR) && uvx --from build pyproject-build
13
+
14
+ app/clean:
15
+ cd $(APP_PKG_DIR) && rm -rf dist/ *.egg-info .venv
16
+
17
+ app/install: app/build
18
+ cd $(APP_PKG_DIR) && uv pip install dist/*.whl
19
+
20
+ app/install-dev:
21
+ cd $(APP_PKG_DIR) && uv pip install -e .
22
+
23
+ endif # APPPY_APP_MK_INCLUDED
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "apppy-app"
7
+ version = "0.1.0"
8
+ description = "Application definitions and infrastructure 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-auth>=0.1.0",
19
+ "apppy-db>=0.1.0",
20
+ "apppy-env>=0.1.0",
21
+ "apppy-fastql>=0.1.0",
22
+ "apppy-logger>=0.1.0",
23
+ "click==8.2.1",
24
+ "dependency-injector==4.48.1",
25
+ "fastapi==0.115.14",
26
+ "fastapi-lifespan-manager==0.1.4",
27
+ "strawberry-graphql[fastapi]==0.275.5",
28
+ "uvicorn==0.35.0"
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/spals/apppy"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/apppy"]
@@ -0,0 +1,256 @@
1
+ import logging
2
+ import subprocess
3
+ import sys
4
+ import typing
5
+ from typing import Any
6
+
7
+ import uvicorn
8
+ from dependency_injector import containers, providers
9
+ from fastapi import FastAPI
10
+ from fastapi_lifespan_manager import LifespanManager
11
+
12
+ from apppy.app.context import (
13
+ create_context_authenticated,
14
+ create_context_unauthenticated,
15
+ )
16
+ from apppy.app.health import DefaultHealthCheck, HealthApi, HealthCheck
17
+ from apppy.app.middleware import (
18
+ JwtAuthMiddleware,
19
+ RequestIdMiddleware,
20
+ SessionMiddleware,
21
+ SessionMiddlewareSettings,
22
+ )
23
+ from apppy.app.version import VersionQuery, VersionSettings
24
+ from apppy.auth.jwks import JwksAuthStorage, JwksAuthStorageSettings
25
+ from apppy.auth.jwt import JwtAuthSettings
26
+ from apppy.auth.oauth import OAuthRegistry, OAuthRegistrySettings
27
+ from apppy.db.migrations import DefaultMigrations, Migrations
28
+ from apppy.db.postgres import PostgresClient, PostgresClientSettings
29
+ from apppy.env import Env
30
+ from apppy.fastql import FastQL
31
+ from apppy.fastql.typed_id import TypedIdEncoder, TypedIdEncoderSettings
32
+ from apppy.fs import FileSystem, FileSystemSettings
33
+ from apppy.fs.local import LocalFileSystem, LocalFileSystemSettings
34
+ from apppy.logger import bootstrap_global_logging
35
+
36
+ _logger = logging.getLogger("apppy.app.App")
37
+
38
+
39
+ def _noop_provider(*args: Any, **kwargs: Any) -> None:
40
+ return None
41
+
42
+
43
+ class App(containers.DeclarativeContainer):
44
+ """
45
+ A conceptual representation of the application. This includes:
46
+
47
+ - A reference to the configuration environment
48
+ - A reference to the application container
49
+ """
50
+
51
+ ##### Basic Application #####
52
+ env: providers.Dependency[Env] = providers.Dependency()
53
+ fastapi: providers.Dependency[FastAPI] = providers.Dependency()
54
+ fastql: providers.Provider[FastQL] = providers.Object(FastQL())
55
+ lifespan: providers.Provider[LifespanManager] = providers.Object(LifespanManager())
56
+
57
+ # Typed Ids
58
+ typed_id_encoder_settings: providers.Provider[TypedIdEncoderSettings] = providers.Singleton(
59
+ TypedIdEncoderSettings, env=env
60
+ )
61
+ typed_id_encoder: providers.Provider[TypedIdEncoder] = providers.Singleton(
62
+ TypedIdEncoder, settings=typed_id_encoder_settings
63
+ )
64
+
65
+ ##### Core Application Systems #####
66
+ # FileSystem
67
+ fs_settings: providers.Provider[FileSystemSettings] = providers.Singleton(
68
+ FileSystemSettings, env=env
69
+ )
70
+ fs: providers.Provider[FileSystem] = providers.Singleton(FileSystem, settings=fs_settings)
71
+ fs_local_settings: providers.Provider[LocalFileSystemSettings] = providers.Singleton(
72
+ LocalFileSystemSettings, env=env
73
+ )
74
+ fs_local: providers.Provider[LocalFileSystem] = providers.Singleton(
75
+ LocalFileSystem, settings=fs_local_settings, fs=fs
76
+ )
77
+
78
+ ##### Microservice: Auth #####
79
+ jwt_auth_settings: providers.Provider[JwtAuthSettings] = providers.Singleton(
80
+ JwtAuthSettings, env=env
81
+ )
82
+ jwks_auth_storage_settings: providers.Provider[JwksAuthStorageSettings] = providers.Singleton(
83
+ JwksAuthStorageSettings, env=env
84
+ )
85
+ jwks_auth_storage: providers.Provider[JwksAuthStorage] = providers.Singleton(
86
+ JwksAuthStorage, settings=jwks_auth_storage_settings, lifespan=lifespan, fs=fs
87
+ )
88
+
89
+ oauth_registry_settings: providers.Provider[OAuthRegistrySettings] = providers.Singleton(
90
+ OAuthRegistrySettings, env=env
91
+ )
92
+ oauth_registry: providers.Provider[OAuthRegistry] = providers.Singleton(
93
+ OAuthRegistry, settings=oauth_registry_settings
94
+ )
95
+
96
+ session_middleware_settings = providers.Singleton(SessionMiddlewareSettings, env=env)
97
+
98
+ ##### Microservice: Database #####
99
+ migrations: providers.Provider[Migrations] = providers.Singleton(DefaultMigrations)
100
+
101
+ postgres_settings: providers.Provider[PostgresClientSettings] = providers.Singleton(
102
+ PostgresClientSettings, env=env
103
+ )
104
+ postgres: providers.Provider[PostgresClient] = providers.Singleton(
105
+ PostgresClient, settings=postgres_settings, lifespan=lifespan
106
+ )
107
+
108
+ ##### Microservice: Health #####
109
+ health_check: providers.Provider[HealthCheck] = providers.Singleton(DefaultHealthCheck)
110
+
111
+ health_api: providers.Provider[HealthApi] = providers.Singleton(
112
+ HealthApi, fastapi=fastapi, health_check=health_check
113
+ )
114
+
115
+ ##### Microservice: Version #####
116
+ version_settings: providers.Provider[VersionSettings] = providers.Singleton(
117
+ VersionSettings, env=env
118
+ )
119
+ version_query = providers.Singleton(
120
+ VersionQuery,
121
+ settings=version_settings,
122
+ fastql=fastql,
123
+ migrations=migrations,
124
+ )
125
+
126
+ # Setup services which are attached to an API, which
127
+ # means that they will not have a chance to be otherwise
128
+ # instantiated.
129
+ setup_global_services = providers.Callable(_noop_provider)
130
+ # This registers all necessary middleware.
131
+ register_middleware = providers.Callable(_noop_provider)
132
+ # This registers all externally facing GraphQL operations.
133
+ register_graphql_operations = providers.Callable(_noop_provider)
134
+ # This registers all externally facing REST API routes.
135
+ register_rest_routes = providers.Callable(_noop_provider)
136
+
137
+ @classmethod
138
+ def create(
139
+ cls,
140
+ fastapi_fn: typing.Callable[[LifespanManager], FastAPI] | None = None,
141
+ inject_auth: bool = True,
142
+ **kwargs,
143
+ ) -> "App":
144
+ # We can pass an environment in one of two ways
145
+ # Either a pre-created environment object, or
146
+ # a name of an environment to load.
147
+ if "env" in kwargs:
148
+ env = kwargs["env"]
149
+ else:
150
+ # TODO: Environment overrides
151
+ env = Env.load(prefix=kwargs.get("env_prefix", "APP"), name=kwargs["env_name"])
152
+
153
+ bootstrap_global_logging(kwargs.get("log_level", logging.INFO))
154
+ _logger.info("Creating application", extra=kwargs)
155
+ app: App = cls()
156
+ app.env.override(providers.Object(env))
157
+
158
+ if fastapi_fn is not None:
159
+ fastapi: FastAPI = fastapi_fn(app.lifespan())
160
+ else:
161
+ fastapi = FastAPI(lifespan=app.lifespan())
162
+ app.fastapi.override(providers.Object(fastapi))
163
+
164
+ _logger.info("Assembling application")
165
+ TypedIdEncoder.set_global(app.typed_id_encoder())
166
+ app.health_api() # Register a health API with each application
167
+ app.version_query() # Register a version API with each application
168
+
169
+ app.fastapi().add_middleware(
170
+ RequestIdMiddleware
171
+ ) # Register a request id middleware with each application
172
+
173
+ # Run hook for individual applications to
174
+ # set up their custom global services
175
+ app.setup_global_services(app)
176
+ if inject_auth is True:
177
+ # We'll instantiate the local file system here
178
+ # to allow for the jwks auth storage to work.
179
+ # However, in production application should use
180
+ # a more robust filesystem (e.g. S3 or Supabase)
181
+ app.fs_local()
182
+ # Pre-build the jwks auth service. Note that we load
183
+ # this singleton for service GraphQL requests but we
184
+ # would like the constructor to be run beforehand here
185
+ # NOTE: This needs to come after the FileSystem
186
+ app.jwks_auth_storage()
187
+
188
+ app.fastapi().add_middleware(
189
+ JwtAuthMiddleware,
190
+ jwt_auth_settings=app.jwt_auth_settings(),
191
+ jwks_auth_storage=app.jwks_auth_storage(),
192
+ )
193
+ app.fastapi().add_middleware(
194
+ SessionMiddleware, settings=app.session_middleware_settings()
195
+ )
196
+
197
+ # Run hook for individual applications to
198
+ # set up their custom middleware and APIs
199
+ app.register_middleware(app)
200
+ app.register_graphql_operations(app)
201
+ app.register_rest_routes(app)
202
+
203
+ _logger.info("Including graphql router in FastAPI")
204
+ fastapi.include_router(
205
+ router=app.fastql().create_router(
206
+ context_getter=(
207
+ create_context_authenticated if inject_auth else create_context_unauthenticated
208
+ ),
209
+ graphiql=(not app.env().is_production),
210
+ ),
211
+ )
212
+
213
+ return app
214
+
215
+ @classmethod
216
+ def create_debug_fastapi(cls) -> "FastAPI":
217
+ # NOTE: A uvicorn factory must be completely self-contained with
218
+ # no runtime configuration. Therefore, we just have to assume
219
+ # that we're running in debug mode and that we're in a local environment.
220
+ app: App = cls.create(
221
+ **{"env_name": "local", "log_level": logging.DEBUG}, # type: ignore[arg-type]
222
+ )
223
+ return app.fastapi()
224
+
225
+ @classmethod
226
+ def run(cls, debug: bool = False, **kwargs) -> None:
227
+ if debug:
228
+ # When in debug using hot reloads, we have to launch asubprocess
229
+ cmd = [
230
+ sys.executable, # python interpreter
231
+ "-m",
232
+ "uvicorn",
233
+ f"{cls.__module__}:{cls.__name__}.create_debug_fastapi",
234
+ "--factory",
235
+ "--host",
236
+ "0.0.0.0",
237
+ "--log-level",
238
+ kwargs.get("log_level", "debug"),
239
+ "--port",
240
+ str(kwargs.get("port", 8000)),
241
+ "--reload",
242
+ "--reload-dir",
243
+ "application",
244
+ ]
245
+ subprocess.run(cmd, check=True)
246
+ else:
247
+ app: App = cls.create(**kwargs)
248
+ uvicorn_config = uvicorn.Config(
249
+ app=app.fastapi(),
250
+ host="0.0.0.0",
251
+ log_level=kwargs.get("log_level", "info"),
252
+ port=kwargs.get("port", 8000),
253
+ reload=False,
254
+ )
255
+ uvicorn_server = uvicorn.Server(uvicorn_config)
256
+ uvicorn_server.run()
@@ -0,0 +1,305 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import sys
5
+ from typing import TypeVar
6
+
7
+ import anyio
8
+ import click
9
+
10
+ from apppy.app import App
11
+ from apppy.fastql import FastQL
12
+
13
+ A = TypeVar("A", bound=App)
14
+
15
+
16
+ def make_app_cli(
17
+ app_type: type[A],
18
+ *,
19
+ app_name: str,
20
+ default_port: int = 8000,
21
+ ) -> click.Group:
22
+ def _create_and_run_app(debug: bool, **kwargs) -> None:
23
+ try:
24
+ app_type.run(debug, **kwargs)
25
+ except KeyboardInterrupt:
26
+ # Gracefully shutdown on keyboard interrupt
27
+ sys.exit(0)
28
+
29
+ @click.group(name="run", help=f"Run an instance of the {app_name} server")
30
+ def group() -> None:
31
+ pass
32
+
33
+ @group.command("check-server")
34
+ def check_server():
35
+ """Construct an instance of the application.
36
+
37
+ This will help determine if there's a configuration issue.
38
+ It is useful to run ahead of integration testing.
39
+ """
40
+ kwargs = {
41
+ "env_name": "ci",
42
+ "log_level": "debug",
43
+ }
44
+
45
+ app_instance = app_type.create(**kwargs)
46
+ assert app_instance is not None
47
+
48
+ # Simulate the asynchronous part of the app startup
49
+ async def _async_app_startup():
50
+ async with app_instance.fastapi().router.lifespan_context(app_instance.fastapi()):
51
+ pass
52
+
53
+ anyio.run(_async_app_startup)
54
+
55
+ @group.command("run-debug")
56
+ @click.option(
57
+ "--port",
58
+ required=False,
59
+ default=default_port,
60
+ type=click.INT,
61
+ help="The port on which to run the application",
62
+ )
63
+ def server_debug(port: int):
64
+ """Run an instance of the application in a local environment in full debug mode"""
65
+
66
+ kwargs = {
67
+ "env_name": "local",
68
+ "log_level": "debug",
69
+ "port": port,
70
+ }
71
+ # NOTE: This is slightly different from the --debug flags below
72
+ # as this will create a fully hot-reloadable server using only
73
+ # default values whereas the --debug flags setup debug mode for
74
+ # the server is the specified environment.
75
+ _create_and_run_app(debug=True, **kwargs)
76
+
77
+ @group.command("run-local")
78
+ @click.option(
79
+ "--port",
80
+ required=False,
81
+ default=default_port,
82
+ type=click.INT,
83
+ help="The port on which to run the application",
84
+ )
85
+ def server_local(port: int):
86
+ """Run an instance of the application in a local environment"""
87
+ """Run the app in a local environment."""
88
+ kwargs = {
89
+ "env_name": "local",
90
+ "log_level": "debug",
91
+ "port": port,
92
+ }
93
+ _create_and_run_app(debug=False, **kwargs)
94
+
95
+ @group.command("run-server")
96
+ @click.option(
97
+ "--debug",
98
+ required=False,
99
+ default=False,
100
+ is_flag=True,
101
+ type=click.BOOL,
102
+ help="Enable debug mode",
103
+ )
104
+ @click.option(
105
+ "--port",
106
+ required=False,
107
+ default=default_port,
108
+ type=click.INT,
109
+ help="The port on which to run the application",
110
+ )
111
+ def server(debug: bool, port: int):
112
+ """Run an instance of the application.
113
+
114
+ This does not force any particular environment, but
115
+ rather reads it from the APP_ENV (or equivalent) environment variable.
116
+ """
117
+ kwargs = {
118
+ "log_level": "debug" if debug else "info",
119
+ "port": port,
120
+ }
121
+ _create_and_run_app(debug=False, **kwargs)
122
+
123
+ def _reorder_schema(schema_content: str) -> str:
124
+ # HACK: We need to reorder the schema definition a little
125
+ # bit in order for some of the codegen tools to work correctly
126
+ # Unfortunately, the native print_schema utility in Strawberry
127
+ # does not allow this so we have to do it here.
128
+
129
+ # CASE: More GraphQLError interface to the top
130
+ pattern = r"(interface GraphQLError\s+{[^}]+})"
131
+ match = re.search(pattern, schema_content)
132
+ if match:
133
+ definition = match.group(1)
134
+ schema_content = schema_content.replace(definition, "")
135
+ return f"{definition}\n\n{schema_content}"
136
+
137
+ return schema_content
138
+
139
+ @group.command("graphql-gen")
140
+ @click.argument(
141
+ "base_dir",
142
+ required=True,
143
+ default=f"graphql/__generated__/{app_name}",
144
+ type=click.Path(file_okay=False, dir_okay=True, writable=True),
145
+ )
146
+ @click.option(
147
+ "--include-fragments",
148
+ default=False,
149
+ required=True,
150
+ is_flag=True,
151
+ help="Generate GraphQL fragment files",
152
+ )
153
+ @click.option(
154
+ "--include-mutations",
155
+ default=False,
156
+ required=True,
157
+ is_flag=True,
158
+ help="Generate GraphQL mutation files",
159
+ )
160
+ @click.option(
161
+ "--include-queries",
162
+ default=False,
163
+ required=True,
164
+ is_flag=True,
165
+ help="Generate GraphQL query files",
166
+ )
167
+ @click.option(
168
+ "--include-schema",
169
+ default=False,
170
+ required=True,
171
+ is_flag=True,
172
+ help="Generate GraphQL schema file",
173
+ )
174
+ @click.option(
175
+ "--schema-file",
176
+ default=f"{app_name}.schema.graphql",
177
+ required=True,
178
+ type=click.Path(file_okay=True, dir_okay=False, writable=True),
179
+ )
180
+ def graphql_gen(
181
+ base_dir: str,
182
+ include_fragments: bool,
183
+ include_mutations: bool,
184
+ include_queries: bool,
185
+ include_schema: bool,
186
+ schema_file: str,
187
+ ):
188
+ """Auto-generate GraphQL files"""
189
+
190
+ # NOTE: All Graphql generation happens in this one function
191
+ # to avoid having to load the FastQL instance multiple times
192
+
193
+ kwargs = {
194
+ "env_name": "ci",
195
+ "log_level": "warning",
196
+ }
197
+ app_instance = app_type.create(**kwargs)
198
+ fastql: FastQL = app_instance.fastql()
199
+
200
+ if include_fragments:
201
+ fragments: dict[str, str] = {}
202
+ types_visited: set[str] = set()
203
+
204
+ for typename, _ in fastql.types_output_metadata:
205
+ fastql.collect_and_print_fragments(typename, types_visited, fragments)
206
+
207
+ for typename, _ in fastql.types_error_metadata:
208
+ fastql.collect_and_print_fragments(typename, types_visited, fragments)
209
+
210
+ fragment_header = (
211
+ "# This fragment file is automatically generated. DO NOT EDIT MANUALLY.\n"
212
+ "# To update, run make gql or make gql-gen or make gql-fragments.\n\n"
213
+ )
214
+
215
+ for name, content in fragments.items():
216
+ fastql.write_graphql_file(
217
+ base_dir=f"{base_dir}/fragments",
218
+ file_name=f"{name}Fragment.graphql",
219
+ file_content=content,
220
+ file_header=fragment_header,
221
+ )
222
+
223
+ click.secho(
224
+ f"✅ Generated {len(fragments)} fragment file(s) in '{base_dir}/fragments'",
225
+ fg="green",
226
+ )
227
+
228
+ if include_mutations:
229
+ if not include_fragments:
230
+ click.secho(
231
+ "Unable to generate mutations without --include-fragments",
232
+ fg="red",
233
+ )
234
+ sys.exit(1)
235
+
236
+ mutations: dict[str, str] = {}
237
+ mutations_visited: set[str] = set()
238
+
239
+ mutation_header = (
240
+ "# This mutation file is automatically generated. DO NOT EDIT MANUALLY.\n"
241
+ "# To update, run make gql or make gql-gen or make gql-mutations.\n\n"
242
+ )
243
+ for m in fastql.mutations_raw:
244
+ fastql.collect_and_print_mutations(type(m).__name__, mutations_visited, mutations)
245
+
246
+ for name, content in mutations.items():
247
+ fastql.write_graphql_file(
248
+ base_dir=f"{base_dir}/mutations",
249
+ file_name=f"{name}.graphql",
250
+ file_content=content,
251
+ file_header=mutation_header,
252
+ )
253
+
254
+ click.secho(
255
+ f"✅ Generated {len(mutations)} mutation file(s) in '{base_dir}/mutations'",
256
+ fg="green",
257
+ )
258
+
259
+ if include_queries:
260
+ if not include_fragments:
261
+ click.secho(
262
+ "Unable to generate queries without --include-fragments",
263
+ fg="red",
264
+ )
265
+ sys.exit(1)
266
+
267
+ queries: dict[str, str] = {}
268
+ queries_visited: set[str] = set()
269
+
270
+ query_header = (
271
+ "# This query file is automatically generated. DO NOT EDIT MANUALLY.\n"
272
+ "# To update, run make gql or make gql-gen or make gql-queries.\n\n"
273
+ )
274
+ for q in fastql.queries_raw:
275
+ fastql.collect_and_print_queries(type(q).__name__, queries_visited, queries)
276
+
277
+ for name, content in queries.items():
278
+ fastql.write_graphql_file(
279
+ base_dir=f"{base_dir}/queries",
280
+ file_name=f"{name}.graphql",
281
+ file_content=content,
282
+ file_header=query_header,
283
+ )
284
+
285
+ click.secho(
286
+ f"✅ Generated {len(queries)} query file(s) in '{base_dir}/queries'",
287
+ fg="green",
288
+ )
289
+
290
+ if include_schema:
291
+ schema_header = (
292
+ "# This schema file is automatically generated. DO NOT EDIT MANUALLY.\n"
293
+ "# To update, run make gql or make gql-gen or make gql-schema.\n\n"
294
+ )
295
+ schema_content = fastql.print_schema()
296
+ schema_content = _reorder_schema(schema_content)
297
+ fastql.write_graphql_file(
298
+ base_dir=f"{base_dir}/schema",
299
+ file_name=schema_file,
300
+ file_content=schema_content,
301
+ file_header=schema_header,
302
+ )
303
+ click.secho(f"✅ Generated schema file '{base_dir}/schema/{schema_file}'", fg="green")
304
+
305
+ return group
@@ -0,0 +1,27 @@
1
+ from dataclasses import dataclass
2
+
3
+ from fastapi import Request
4
+ from strawberry.fastapi.context import BaseContext
5
+
6
+ from apppy.auth.jwt import JwtAuthContext
7
+
8
+
9
+ @dataclass
10
+ class UnauthenticatedAppContext(BaseContext):
11
+ request: Request
12
+
13
+
14
+ def create_context_unauthenticated(request: Request) -> UnauthenticatedAppContext:
15
+ return UnauthenticatedAppContext(request=request)
16
+
17
+
18
+ @dataclass
19
+ class AuthenticatedAppContext(BaseContext):
20
+ auth: JwtAuthContext
21
+ request: Request
22
+
23
+
24
+ def create_context_authenticated(request: Request) -> AuthenticatedAppContext:
25
+ # NOTE: The current JwtAuthContext is set in JwtAuthMiddleware
26
+ auth_ctx: JwtAuthContext = JwtAuthContext.current_auth_context()
27
+ return AuthenticatedAppContext(auth=auth_ctx, request=request)
@@ -0,0 +1,38 @@
1
+ import abc
2
+
3
+ from fastapi import APIRouter, FastAPI
4
+
5
+ from apppy.logger import WithLogger
6
+
7
+
8
+ class HealthCheck(abc.ABC):
9
+ @abc.abstractmethod
10
+ def health_check(self) -> bool:
11
+ pass
12
+
13
+ def ping(self) -> str:
14
+ return "ok"
15
+
16
+
17
+ class DefaultHealthCheck(HealthCheck):
18
+ def health_check(self) -> bool:
19
+ return True
20
+
21
+
22
+ class HealthApi(WithLogger):
23
+ def __init__(self, fastapi: FastAPI, health_check: HealthCheck):
24
+ self._health_check: HealthCheck = health_check
25
+ fastapi.include_router(self.__create_router())
26
+
27
+ def __create_router(self) -> APIRouter:
28
+ router = APIRouter()
29
+
30
+ @router.get("/health/check")
31
+ async def health_check():
32
+ return {"healthy", self._health_check.health_check()}
33
+
34
+ @router.get("/health/ping")
35
+ async def health_ping():
36
+ return {"ping": self._health_check.ping()}
37
+
38
+ return router
@@ -0,0 +1,113 @@
1
+ import uuid
2
+ from contextlib import suppress
3
+
4
+ from fastapi import BackgroundTasks, FastAPI, Request
5
+ from fastapi_another_jwt_auth import AuthJWT as JWT
6
+ from pydantic import Field
7
+ from starlette.middleware.base import BaseHTTPMiddleware as StarletteHTTPMiddleware
8
+ from starlette.middleware.sessions import SessionMiddleware as StarletteSessionMiddleware
9
+
10
+ from apppy.auth.errors.service import (
11
+ ServiceKeyAlgorithmMissingError,
12
+ )
13
+ from apppy.auth.jwks import JwkInfo, JwksAuthStorage
14
+ from apppy.auth.jwt import JwtAuthContext, JwtAuthSettings
15
+ from apppy.env import EnvSettings
16
+ from apppy.fastql.errors import GraphQLError
17
+ from apppy.logger.storage import LoggingStorage
18
+
19
+
20
+ class JwtAuthMiddleware(StarletteHTTPMiddleware):
21
+ """
22
+ A middleware instance which analyzes the request headers,
23
+ creates a JwtAuthContext, and set it in thread local storage
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ fastapi: FastAPI,
29
+ jwt_auth_settings: JwtAuthSettings,
30
+ jwks_auth_storage: JwksAuthStorage,
31
+ ):
32
+ super().__init__(app=fastapi)
33
+ self._jwks_auth_storage = jwks_auth_storage
34
+
35
+ # Load the global JWT configuration JWT.
36
+ # This is used for processing user requests below.
37
+ @JWT.load_config
38
+ def load_config_jwtauth_global():
39
+ return jwt_auth_settings
40
+
41
+ async def dispatch(self, request: Request, call_next):
42
+ jwt_headers: dict | None = JwtAuthContext.peek(request)
43
+
44
+ try:
45
+ if jwt_headers is not None and "kid" in jwt_headers:
46
+ # CASE: A Service is making the request
47
+ jwk_info: JwkInfo = self._jwks_auth_storage.get_jwk(jwt_headers["kid"])
48
+
49
+ if "alg" not in jwt_headers:
50
+ raise ServiceKeyAlgorithmMissingError()
51
+
52
+ public_key = jwk_info.jwk.get_op_key("verify")
53
+ auth_ctx = JwtAuthContext.from_service_request(
54
+ request, jwt_headers["alg"], public_key
55
+ )
56
+ else:
57
+ # CASE: A User is making the request
58
+ # Instead of using the JWKS storage, we'll
59
+ # use the global configuration loaded in __init__
60
+ auth_ctx = JwtAuthContext.from_user_request(request)
61
+ except GraphQLError as e:
62
+ # If we encounter an error while preprocessing the
63
+ # auth context, we'll capture the error and keep going. The
64
+ # authentication and authorization permission are designed
65
+ # to handle this and raise the appropriate error.
66
+ auth_ctx = JwtAuthContext(preprocessing_error=e)
67
+
68
+ JwtAuthContext.set_current_auth_context(auth_ctx)
69
+ response = await call_next(request)
70
+ return response
71
+
72
+
73
+ class RequestIdMiddleware(StarletteHTTPMiddleware):
74
+ def __init__(self, fastapi: FastAPI, header_name: str = "X-Request-ID"):
75
+ super().__init__(fastapi)
76
+ self.header_name = header_name
77
+
78
+ async def dispatch(self, request: Request, call_next):
79
+ request_id = str(uuid.uuid4())
80
+
81
+ # Store in LoggingStorage (thread-local)
82
+ with suppress(RuntimeError):
83
+ LoggingStorage.get_global().add_request_id(request_id)
84
+
85
+ response = await call_next(request)
86
+
87
+ # Cleanup after the response has been sent
88
+ def cleanup_logging_storage():
89
+ with suppress(RuntimeError):
90
+ LoggingStorage.get_global().reset()
91
+
92
+ # Ensure background task manager exists
93
+ if response.background is None:
94
+ response.background = BackgroundTasks()
95
+
96
+ response.background.add_task(cleanup_logging_storage)
97
+ response.headers.setdefault(self.header_name, request_id)
98
+
99
+ return response
100
+
101
+
102
+ class SessionMiddlewareSettings(EnvSettings):
103
+ secret_key: str = Field(alias="APP_SESSION_MIDDLEWARE_SECRET_KEY", exclude=True)
104
+
105
+
106
+ class SessionMiddleware(StarletteSessionMiddleware):
107
+ """
108
+ Simple wrapper around Starlette's SessionMiddleware to allow for
109
+ injected settings via EnvSettings
110
+ """
111
+
112
+ def __init__(self, fastapi: FastAPI, settings: SessionMiddlewareSettings):
113
+ super().__init__(app=fastapi, secret_key=settings.secret_key)
@@ -0,0 +1,43 @@
1
+ from dataclasses import dataclass
2
+
3
+ import strawberry
4
+ from pydantic import Field
5
+
6
+ from apppy.db.migrations import Migrations
7
+ from apppy.env import EnvSettings
8
+ from apppy.fastql import FastQL
9
+ from apppy.fastql.annotation import fastql_query, fastql_query_field, fastql_type_output
10
+ from apppy.logger import WithLogger
11
+
12
+
13
+ class VersionSettings(EnvSettings):
14
+ commit: str = Field(alias="APP_VERSION_COMMIT", default="local")
15
+ release: str = Field(alias="APP_VERSION_RELEASE", default="local")
16
+
17
+
18
+ @dataclass
19
+ @fastql_type_output
20
+ class VersionApiOutput:
21
+ commit: str
22
+ migration: str | None
23
+ release: str
24
+
25
+
26
+ @fastql_query()
27
+ class VersionQuery(WithLogger):
28
+ def __init__(self, settings: VersionSettings, fastql: FastQL, migrations: Migrations) -> None:
29
+ self._settings = settings
30
+ self._migrations = migrations
31
+ fastql.include_in_schema(self)
32
+
33
+ @fastql_query_field(
34
+ skip_permission_checks=True,
35
+ )
36
+ async def version(self, info: strawberry.Info) -> VersionApiOutput:
37
+ version_output = VersionApiOutput(
38
+ commit=self._settings.commit,
39
+ migration=(await self._migrations.head()),
40
+ release=self._settings.release,
41
+ )
42
+
43
+ return version_output