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.
- apppy_app-0.1.0/.gitignore +28 -0
- apppy_app-0.1.0/PKG-INFO +21 -0
- apppy_app-0.1.0/README.md +0 -0
- apppy_app-0.1.0/app.mk +23 -0
- apppy_app-0.1.0/pyproject.toml +35 -0
- apppy_app-0.1.0/src/apppy/app/__init__.py +256 -0
- apppy_app-0.1.0/src/apppy/app/cli.py +305 -0
- apppy_app-0.1.0/src/apppy/app/context.py +27 -0
- apppy_app-0.1.0/src/apppy/app/health.py +38 -0
- apppy_app-0.1.0/src/apppy/app/middleware.py +113 -0
- apppy_app-0.1.0/src/apppy/app/version.py +43 -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
|
+
|
apppy_app-0.1.0/PKG-INFO
ADDED
|
@@ -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
|