diff --git a/README.md b/README.md index f9e4c7a..13a1ed0 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,1498 @@ Apiens ====== -TODO +Apiens (API sapiens) is a collection of tools for developing API applications with FastAPI and/or GraphQL. -The library is under development. The docs are underway. +It includes some solutions and best practices for: + +* Application configuration with env variables +* Error reporting with error codenames and structured error info +* Failsafe tools to help write better async code +* SqlAlchemy tools for loading, saving, and inspecting objects + +Because this library is not a framework but a collection of tools, our docs start with a guide. +We'll create a new application :) + + + + + + +Guide +===== +We will build a new GraphQL application that sits on top of FastAPI application. + +Demo application sources are available under [misc/readme-app](misc/readme-app/). + + + + + + +## Application Configuration: Environment Variables +Our application will support 3 running modes: + +1. `dev`: Development mode. Your code can enable some debugging features when this mode is on. +2. `prod`: Production mode. All debugging features are disabled. +3. `test:` Testing mode. Used when running unit-tests. + +The current environment is determined by the `ENV` environment variable. Like this: + +```console +$ ENV=prod app run +``` + + + + + + +### Configuration file +Our configuration will live in a module as a variable that's easy to import like this: + +```python +from app.globals.config import settings +``` + +Here's the module: + +```python +# config.py + +import pydantic as pd + +class Settings(pd.BaseSettings): + """ Application settings """ + # Human-readable name of this project + # Used in titles, emails & stuff + PROJECT_NAME: str = 'My Application' + + class Config: + # Use this prefix for environment variable names. + env_prefix = 'APP_' + case_sensitive = True + + +# Load default environment values from .env files. +from apiens.tools.settings import ( + set_default_environment, + load_environment_defaults_for, + switch_environment_when_running_tests, +) +set_default_environment('APP_ENV', default_environment='dev') +load_environment_defaults_for('APP_ENV') +switch_environment_when_running_tests('APP_ENV') + + +# Init settings +settings = Settings() +``` + + + + + + +### DotEnv files +The application will read its configuration from environment variables, but will also load the following dotenv files: + +* `/misc/env/{name}.env`: Configuration variables' values +* `/misc/env.local/{name}.env`: Configuration overrides for running in the so-called "local mode" (see below) +* `/.{name}.env`: Ad-hoc variable value overrides + +So, if your current environment is + +```console +$ export ENV=dev && cd ~/code/myapp +``` + +Then it will: + +* Load `/misc/env/dev.env` +* Load `/misc/env.local/dev.env` (only when running locally) +* Load `/.dev.env` (only when running locally) +* Read variable values from the environment and override any previous values + +This logic is implemented in the [`load_environment_defaults_for()`](apiens/tools/settings/env.py). + + + + + + + +### Running Locally +When your application runs in Docker, it needs to use `postgres` as the host name. +However, when you run the same application locally, on your host, it needs to use `localhost` as the host name. + +To support the "local" running mode, we have this `/misc/env.local` folder. +To activate this mode, use the following `.envrc` [direnv](https://direnv.net/) file: + +```bash +#!/bin/bash +export ENV_RUNNING_LOCALLY=1 +``` + + + + + + +### direnv file +Okay, while we're at it, here's a `.envrc` file that will load all `.env` files into the current environment: + +```bash +#!/bin/bash + +# auto-set the environment +[ -z "${APP_ENV}" ] && export APP_ENV='dev' + +# auto-load .env into the environment +[ -f "misc/envs/${APP_ENV}.env" ] && dotenv "misc/envs/${APP_ENV}.env" +[ -f "misc/envs.local/${APP_ENV}.env" ] && dotenv "misc/envs.local/${APP_ENV}.env" +[ -f ".${APP_ENV}.env" ] && dotenv ".${APP_ENV}.env" + +# Indicate that we are running locally (not in Docker) +export ENV_RUNNING_LOCALLY=1 + +# Custom PS1 +# Your shell must have an "echo -n $CUSTOM_PS1" in order for this to work +export CUSTOM_PS1="$PREVENT_ENV: " + +# Automatically activate poetry virtualenv +if [[ -f "pyproject.toml" ]]; then + # create venv if it doesn't exist; then print the path to this virtualenv + export VIRTUAL_ENV=$(poetry run true && poetry env info --path) + export POETRY_ACTIVE=1 + PATH_add "$VIRTUAL_ENV/bin" + echo "Activated Poetry virtualenv: $(basename "$VIRTUAL_ENV")" +fi +``` + + + + + + +### Configuration Mixins +The [tools.settings.mixins](apiens/tools/settings/mixins.py) provides some mixin classes for your `Settings` class. + +The `EnvMixin` allows you to check the current environment, like this: + +```python +if not settings.is_production: + ... # add more debugging information +``` + +The `LocaleMixin` is a convenience for storing the current locale (language) and timezone: + +```python +settings.LOCALE # -> 'en' +settings.TZ # -> 'Europe/Moscow' +``` + +The `DomainMixin` and `CorsMixin` help you configure your web application for allowed Hosts and CORS checks: + +```python +settings.SERVER_URL # -> 'https://example.com/' +settings.CORS_ORIGINS # -> ['https://example.com', 'https://example.com:443'] +``` + +The `SecretMixin` is for keeping your application's secret key for encryption and stuff: + +```python +settings.SECRET_KEY # -> '...' +``` + +The `PostgresMixin` reads Postgres configuration from the environment variables you also use for the Docker container: + +```python +# Read from the environment: +settings.POSTGRES_HOST # -> 'localhost' +# Generated: +settings.POSTGRES_URL # -> 'postgres://user:pass@localhost:port/' +``` + +The `RedisMixin` reads Redis configuration: + +```python +settings.REDIS_URL # -> 'redis://@localhost/0 +``` + +Now, in our Demo application, we'll use some mixins to get a Postgres connection and settings for the web application: + +```python +# config.py +class Settings(EnvMixin, DomainMixin, CorsMixin, PostgresMixin, pd.BaseSettings): + """ Application settings """ + + # Human-readable name of this project + # Used in titles, emails & stuff + PROJECT_NAME: str = 'My Application' + + class Config: + # Use this prefix for environment variable names. + env_prefix = 'APP_' + case_sensitive = True +``` + + + + + + +### DotEnv files +Now, when we have the `Settings` class that reads our configuration into `config.settings`, +we need to give it the actual configuration values. + +These values can come from the environment: + +```python +$ ENV=dev POSTGRES_HOST=localhost POSTGRES_PORT=54321 APP_SERVER_URL=http://localhost/ app run +``` + +but surely this is too verbose. + +Instead, we'll create DotEnv files under `misc/env`: + +```env +# misc/env/dev.env +ENV=dev + +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=app + +APP_SERVER_URL=http://localhost +APP_CORS_ORIGINS=http://localhost +``` + +And another one, for unit-tests: + +```env +ENV=test + +POSTGRES_HOST=localhost +POSTGRES_PORT=54321 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=app_test + +APP_SERVER_URL=http://localhost +APP_CORS_ORIGINS=http://localhost +``` + +Note that a different database should be used for unit-tests: otherwise every test run would overwrite your local database =\ + + + + + + +## Database init +Let's configure a Postgres database using our configuration: + +```python +# postgres.py +from collections import abc +import sqlalchemy as sa + +from apiens.tools.settings import unit + +# Import settings +from app.globals.config import settings + + +# Prepare some units for readability +min = unit('minute') +sec = unit('second') + +# Initialize SqlAlchemy Engine: the connection pool +engine: sa.engine.Engine = sa.create_engine( + # Use settings + settings.POSTGRES_URL, + future=True, + # Configure the pool of connections + pool_size=20, + max_overflow=10, + pool_pre_ping=True, + # Use `unit` to make the value more readable + pool_recycle=10 * min >> sec, +) + +# Initialize the SessionMaker: the way to get a SqlAlchemy Session +from sqlalchemy.orm import Session +SessionMakerFn = abc.Callable[[], Session] + +SessionMaker: SessionMakerFn = sa.orm.sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, +) +``` + +Thanks to `settings` being a module variable, we can just import it, and initialize our database as module variables. +Now, your code can use the database like this: + +```python +from app.globals.postgres import SessionMaker + +with SessionMaker() as ssn: + ... # do your stuff +``` + + + + + + +### DB Connection Pool +It's really important to use `SessionMaker` as a context manager: because this way you make sure that every connection +you've checked out will then be returned to the pool for reuse. + +However, if you used your sessions like this: + +```python +ssn = SessionMaker() +... +ssn.close() +``` + +There is a chance that an exception will be raised before you do `ssn.close()` and the connection will remain checked out. +Such a connection will waste the limited resources of the pool and your application will malfunction. + +Because it's so important to make sure that every checked out connection is returned to the pool, +we add a piece of code that users [`TrackingSessionMaker`](apiens/tools/sqlalchemy/session/session_tracking.py) +that checks whether all your SqlAlchemy connections have been properly closed: + +```python +# In testing, make sure that every connection is properly closed. +if settings.is_testing: + # Prepare a new SessionMaker that tracks every opened connection + from apiens.tools.sqlalchemy.session.session_tracking import TrackingSessionMaker + SessionMaker = TrackingSessionMaker(class_=SessionMaker.class_, **SessionMaker.kw) + + # Define a function for unit-tests: check that all Sessions were properly close()d + def assert_no_active_sqlalchemy_sessions(): + SessionMaker.assert_no_active_sessions() +# postgres.py +... +# In testing, make sure that every connection is properly closed. +if settings.is_testing: + # Prepare a new SessionMaker that tracks every opened connection + from apiens.tools.sqlalchemy.session.session_tracking import TrackingSessionMaker + SessionMaker = TrackingSessionMaker(class_=SessionMaker.class_, **SessionMaker.kw) + + # Define a function for unit-tests: check that all Sessions were properly close()d + def assert_no_active_sqlalchemy_sessions(): + SessionMaker.assert_no_active_sessions() +``` + +This code defines a function, `assert_no_active_sqlalchemy_sessions()`, that should be run after each unit-test. +If your code hasn't `close()`d a Session, this function will find a dangling Session and complain. + +This is your safeguard against connections left open unintentionally. + + + + + + +## Structured Errors +This is how default FastAPI errors look like: + +```javascript +{ + detail: "Not Found" +} +``` + +and this is how default GraphQL errors look like: + +```javascript +{ + //... + "errors": [ + { + "message": "Fail", + "path": ["unexpected_error"], + "extensions": { + "exception": { + "stacktrace": ["Traceback", "File ..., line ... in execute_field"], + "context": {/*...*/} + } + } + } + ] +} +``` + +This is ok for developers, but not for the UI: in order to learn what has happened, the UI would have to parse error message. + +Apiens offers *structured errors*: errors where every error has a codename. + + + + + + +### Application Errors +With Apiens, you'll have two types of exceptions: + +* Ordinary Python exceptions: seen as *unexpected errors* +* Application Errors: errors that are meant to be reported to the API user + +Application errors inherit from [BaseApplicationError](apiens/error/base.py) and have the following fields: + +* `error`: The negative message: what has gone wrong. +* `fixit`: The positive message: what the user can do to fix it (user-friendly) + + Thus, every error will have two messages: one for developers, and the other one -- for users. + +* `name`: Error codename: the name of the class. E.g. "E_NOT_FOUND". + + This is the machine-readable codename for the error that the UI can use to react to it. + +* `title`: Generic name for the error class that does not depend on the context. E.g. "not found". +* `httpcode`: The HTTP code to use for this error (when in HTTP context) +* `info`: Additional structured information about the error +* `debug`: Additional structured information, only included when the app is not in production + +Application errors can be raised like this: + +```python +from apiens.error import exc + +raise exc.E_NOT_FOUND( + # Error: the negative message + "User not found by email", + # Fixit: the positive message + "Please check the provided email", + # additional information will be included as `info` + object='User', + email='user@example.com', +) +``` + +Such an error will be reported as structured JSON Error Object by FastAPI: + +```javascript +{ + // Error response starts with the "error" key + error: { + // Error codename + name: "E_NOT_FOUND", + // Two error messages: negative `error`, and positive `fixit` + error: "User not found by email", + fixit: "Please check the provided email", + // Static information + httpcode: 404, + title: "Not found", + // Structured information about this error + info: { + // UI can use this information for display + object: "User", + email: "user@example.com" + }, + debug: { } + } +} +``` + +To integrate with this Application Errors framework, we first need to create a file for our application's exceptions. +For starters, we'll reuse some of the standard exceptions pre-defined in the [apiens.error.exc](apiens/error/exc.py) module: + +```python +# exc.py +import apiens.error.exc + +# Base class for Application Errors +from apiens.error import BaseApplicationError + +# Specific application errors +# We'll reuse some errors provided by apiens +from apiens.error.exc import ( + E_API_ARGUMENT, + E_API_ACTION, + E_NOT_FOUND, + F_FAIL, + F_UNEXPECTED_ERRORS, +) +``` + +And then we'll install an exception handler (FastAPI) and an error formatter (GraphQL) to render such errors properly. + +Because these errors are meant to be returned by the API, they have API schemas: + +* : TypedDict definitions for the Error Object +* : Pydantic definitions for the Error Object (to be used with FastAPI) +* : GraphQL definitions for the Error Object + + + + + + +### Converting Errors +By convention, every uncaught Python exception is seen as *unexpected error* and is reported as "F_UNEXPECTED_ERROR". +This is achieved using the so-called "converting" decorator/context manager: +[converting_unexpected_errors()](apiens/error/converting/exception.py) + +```python +from apiens.error.converting.exception import converting_unexpected_errors + +# Convert every Python exception to `F_UNEXPECTED_ERROR` +with converting_unexpected_errors(): + raise RuntimeError('Fail') +``` + +In some cases you may want to customize how a Python error gets converted into an Application error: +for instance, you may have an API client with custom errors that you want to report as `F_NETWORK_SERVICE` or something. + +To customize how errors are converted into application errors, define a `default_api_error()` method on them. +See [ConvertsToBaseApiExceptionInterface](apiens/error/converting/base.py) protocol. + +Note that there are other *converting* decorators as well: + +* [`converting_sa_errors()`](apiens/error/converting/sqlalchemy.py) converts SqlAlchemy errors: + for instance, `sa.orm.exc.NoResultFound` gets converted into `E_NOT_FOUND`, and `UniqueViolation` into `E_CONFLICT_DUPLICATE`. +* [`converting_jessiql_errors()`](apiens/error/converting/jessiql.py) converts JessiQL errors into `E_API_ARGUMENT`. +* [`converting_apiens_errors()`](apiens/error/converting/apiens.py) + + + + + + +## FastAPI application +Let's start by putting together a FastAPI application. + +```python +# fastapi/app.py +from fastapi import FastAPI + +from app.globals.config import settings + + +# ASGI app +asgi_app = FastAPI( + title=settings.PROJECT_NAME, + description=""" Test app """, + # `settings` controls debug mode of FastAPI + debug=not settings.is_production, +) + +# Attach GraphQL routes +from app.expose.graphql.app import graphql_app +asgi_app.mount('/graphql/', graphql_app) +``` + +This part of the application is pretty straightforward: we initialized an ASGI application, +and mounted a GraphQL ASGI application onto `/graphql/` route. + +Let's see how such an API reports a `RuntimeError`: + +```javascript +Internal Server Error +``` + +By adding using [`register_application_exception_handlers()`](apiens/tools/fastapi/exception_handlers.py), +we can improve the way errors are reported: they will become structured Application Errors: + +```python +# fastapi/app.py +from apiens.tools.fastapi.exception_handlers import register_application_exception_handlers +register_application_exception_handlers(asgi_app, passthru=settings.is_testing) +``` + +They will: + +* Report every error as `F_UNEXPECTED_ERROR` +* Report FastAPI validation errors as `E_CLIENT_VALIDATION` +* Report every application error as proper JSON object +* For invalid urls ("route not found"), will generate a list of suggested API paths + + + + + + +## GraphQL application + +Let's start a GraphQL application. First, we create a GraphQL schema, and then create an ASGI application +that serves the schema. + + + + + + +### GraphQL Schema + +```python +# graphql/schema.py + +import os +import ariadne + +import apiens.error.error_object +from apiens.tools.ariadne.schema.load import load_schema_from_module + +app_schema = ariadne.make_executable_schema([ + # Load all *.graphql files from this folder + ariadne.load_schema_from_path(os.path.dirname(__file__)), + # Load a *.graphql file from a module + load_schema_from_module(apiens.error.error_object, 'schema.graphql'), + ], + graphql_definitions, + ariadne.snake_case_fallback_resolvers, +) +``` + +Let's add a few more line to improve how `Int` and `Float` errors are reported: +human-readable error messages are implemented by +[`human_readable.install_types_to_schema()`](apiens/tools/graphql/errors/human_readable.py) +that overrides some built-in resolvers with user-friendly error messages that can now be used in the UI: + +```python +# graphql/schema.py + +# Improve error messages from scalars like Int and Float +from apiens.tools.graphql.errors import human_readable +human_readable.install_types_to_schema(app_schema) +``` + +This module, `human_readable`, improves the + +Before: + +> 'message': "Int cannot represent non-integer value: 'INVALID'", + +After: + +> 'message': "Not a valid number", # Improved, human-readable + + + + + + +### GraphQL ASGI Application + +Now we need to create an ASGI application. + +This is necessary because the GraphQL schema knows nothing about HTTP requests, JSON payload, etc. +This ASGI application takes JSON data from the request body and passes it to the schema for execution. +The schema gives the response, which is converted into HTTP JSON response and sent to the client. + +So, let's init this application with Ariadne: + +```python +from ariadne.asgi import GraphQL +from apiens.tools.graphql.middleware.documented_errors import documented_errors_middleware +from apiens.tools.graphql.middleware.unexpected_errors import unexpected_errors_middleware +from apiens.tools.ariadne.errors.format_error import application_error_formatter + +from app.globals.config import settings +from app import exc +from .schema import app_schema + + +# Init the ASGI application +graphql_app = GraphQL( + # The schema to execute operations against + schema=app_schema, + # The context value. None yet. + context_value=None, + # Error formatter presents Application Errors as proper JSON Error Objects + error_formatter=application_error_formatter, + # Developer features are only available when not in production + introspection=not settings.is_production, + debug=not settings.is_production, + # Some middleware + middleware=[ + # This middleware makes sure that every application error is documented. + # That is, if `E_NOT_FOUND` can be returned by your `getUserById`, + # then its docstring should contain something like this: + # > Errors: E_NOT_FOUND: the user is not found + documented_errors_middleware(exc=exc), + + # Converts every Python exception into F_UNEXPECTED_ERROR. + # Users `converting_unexpected_errors()` + unexpected_errors_middleware(exc=exc), + ], +) +``` + +The main part is where the ASGI application gets the `schema`. + +Let's have a closer look at the `middleware` and the `error_formatter` keys. + + + + + +#### GraphQL Middleware + +This initializer mentions two middlewares: + +* [`documented_errors_middleware`](apiens/tools/graphql/middleware/documented_errors.py) + requires that every Application Error is documented. + That is, if your field raises `E_NOT_FOUND`, your field's docstring must contain something like + + > Error: E_NOT_FOUND: the user is not found + + The middleware simply checks whether the docstring of the field, or the parent object, + mentions the error by name. This simple check makes sure that the UI won't get any surprises + from your GraphQL API: it will know exactly which errors it should expect. + + If an error is undocumented, an additional error is reported. + +* [`unexpected_errors_middleware`](apiens/tools/graphql/middleware/unexpected_errors.py) + converts every Python exception into `F_UNEXPECTED_ERROR`. + This basically makes sure that unexpected errors are also reported as application errors. + + + + + + +#### GraphQL Error Formatter +This ASGI application initializer also has a custom error formatter. + +The error formatter, [`application_error_formatter`](apiens/tools/ariadne/errors/format_error.py), is a function that gets a `GraphQLError` +and adds a key to `'extensions'` with additional information about this error. +Namely, if it's an Application Error, it will contain its `name`, `error`, `fixit`, and other fields. + +Before: + +```javascript +{ + "errors": [ + { + "message": "Fail", + "path": ["unexpected_error"], + "extensions": { + "exception": { + // Python traceback + "stacktrace": ["Traceback:", "..."], + } + } + } + ] +} +``` + +After: + +```javascript +{ + "errors": [ + { + "message": "Fail", + "path": ["unexpected_error"], + "extensions": { + // The Error Object: additional, structured, information about the error + "error": { + // Error codename + "name": "F_UNEXPECTED_ERROR", + // Static information: http code, error message + "httpcode": 500, + "title": "Generic server error", + // Two error messages: negative, and positive + "error": "Fail", + "fixit": "Please try again in a couple of minutes. If the error does not go away, contact support and describe the issue", + // Additional structured information, if any + "info": {}, + // Additional debug information (only included in non-production mode) + "debug": { + "errors": [ + { + // The original Python exception + "type": "RuntimeError", + "msg": "Fail", + // Easy-to-read traceback + "trace": [ + "middleware/unexpected_errors.py:unexpected_errors_middleware_impl", + "middleware/documented_errors.py:middleware", + "graphql/query.py:resolve_unexpected_error" + ] + } + ] + } + }, + "exception": { + "stacktrace": ["Traceback:", "..."], + } + } + } + ] +} +``` + +It also fixes some validation errors to be more readable: +original validation errors look like this: + +> Variable '$user' got invalid value -1 at 'user.age'; Expected type 'PositiveInt'. Must be positive + +Such error messages are converted into: + +```javascript +{ + // The message is now human-readable + 'message': "Must be positive", + 'extensions': { + // Validation error is returned as structured data + 'validation': { + 'variable': '$user', + 'path': ['user', 'age'], + } + } +} +``` + + + + + + +### Resolver Markers + +Let's implement a resolver. +A resolver is a Python function that gets called to provide a value for the field: + +```python +import ariadne + +Query = ariadne.QueryType() + +@Query.field('hello') +def resolve_hello(_, info: ResolveInfo): + return 'Welcome' +``` + +Not here's the catch: your resolver will get executed inside the async loop. +It's fine when you use `async` functions or functions that do not block. + +However, if your sync resolver function is blocking -- i.e. is I/O bound or CPU bound -- +then you cannot just use it like this: it would block the whole asyncio loop! + +You need to run it in a threadpool, like this: + +```python +from apiens.tools.python.threadpool import runs_in_threadpool + +@runs_in_threadpool +def resolve_something(_, info): + with SessionMaker() as ssn: + something = ssn.query(...).one() # blocking!!! +``` + +This thing is, probably, **the** most important to know about async applications. +Because it is so important to remember this decorator, the Apiens library provides a way to make sure +that developers do not forget about this issue. + +It provides three decorators: + +* [`@resolves_in_threadpool`](apiens/tools/graphql/resolver/resolver_marker.py) + to decorate a blocking sync function that will be run in a threadpool. +* [`@resolves_nonblocking`](apiens/tools/graphql/resolver/resolver_marker.py) + to decorate a sync function that promises not to block (i.e. do no networking and such) +* [`@resolves_async`](apiens/tools/graphql/resolver/resolver_marker.py) + -- an optional decorator for async functions. Just for completeness. + +And a unit-test tool to make sure that your schema has every resolver decorated: + +```python +# tests/test_api.py + + +from app.expose.graphql.schema import app_schema +from apiens.tools.graphql.resolver.resolver_marker import assert_no_unmarked_resolvers + + +def test_no_unmarked_resolvers(): + """ Make sure that every resolver is properly decorated. """ + assert_no_unmarked_resolvers(app_schema) + +``` + +If any resolver is not decorated, the unit-test would fail like this: + +> AssertionError: Some of your resolvers are not properly marked. Please decorate with either @resolves_in_threadpool or @resolves_nonblocking. +> +> List of undecorated resolvers: +> \* resolve_hello (module: app.expose.graphql.query) + + + + + +## Testing + +We'll start with configuring an API client to make requests to our API. + +Now, there are two ways to make there requests: + +1. We can execute operations against the GraphQL `schema` through `schema.execute()`. + + In this case, we need to provide the `context` value as if the request has come from some HTTP request (because GraphQL does not know anything about HTTP). + + Such a test would verify that your business-logic works, but won't make sure that your application + correctly integrates with the HTTP layer. + +2. We can execute requests through the FastAPI stack and GraphQL ASGI application. + + This test involves the whole application stack, but it's slower to set up, and may lose some valuable information + about exceptions because they are converted into JSON. + +The recommendation is to unit-test the GraphQL application because it's faster and more flexible, +but also pick some key APIs and unit-test their features involving the whole stack. This is especially true about +any APIs that are supposed to use cookies, authentication, user state, and HTTP headers. + +Let's start by defining a GraphQL client and an API client in `conftest.py`. + + + + + + +### GraphQL client + +Apiens provides a convenient [GraphQLTestClient](apiens/tools/graphql/testing/test_client.py) client +for testing operations against your graphql schema: + +```python +# tests/conftest.py + +from app.expose.graphql.schema import app_schema +from apiens.tools.graphql.testing import test_client + + +class GraphQLClient(test_client.GraphQLTestClient): + def __init__(self): + super().__init__(schema=app_schema, debug=True) + + # The GraphQL unit test needs to initialize its own context for the request. + @contextmanager + def init_context_sync(self): + yield {} + +@pytest.fixture() +def graphql_client() -> GraphQLClient: + """ Test client for GraphQL schema """ + with GraphQLClient() as c: + yield c +``` + +When you run GraphQL queries with this test client, you can benefit from the augmented +[`GraphQLResult`](apiens/tools/graphql/testing/query.py) result object: + +```python +# tests/test_hello.py + +from .conftest import GraphQLClient, ApiClient + +def test_hello(graphql_client: GraphQLClient): + q_hello = """ + query { + hello + } + """ + + # Execute operation, inspect response + res = graphql_client.execute(q_hello) + assert res.data['hello'] == 'Welcome' + + # Execute operation, inspect response, expect it to be successful. + # This shortcut res['hello'] would raise any exceptions that may have happened. + res = graphql_client.execute(q_hello) + assert res['hello'] == 'Welcome' +``` + +In addition to this `res[fieldName]` shortcut, [`GraphQLResult`](apiens/tools/graphql/testing/query.py) result object +offers some more features: + +* `data`: the dict with results +* `errors`: the list of reported errors (as JSON) +* `exceptions`: the list of original Python exception objects (not JSON). Useful for re-raising. +* `context`: the context used for the request. Useful for post-mortem inspection. +* `ok`: was the request successful (i.e. did it go without errors?) +* `raise_errors()`: raise any Python exceptions that may have happened. +* `app_error_name`: get the name of the application error (e.g. `"E_NOT_FOUND"`) +* Also see: `app_error`, `original_error`, `graphql_error` properties that help inspect the returned error: + + + + + + +### FastAPI client + +Now we need another client that unit-tests APIs and involves the whole stack, i.e. the FastAPI application. +In fact, you can use the ordinary `fastapi.testing.TestClient`, but it will be quite inconvenient to use it +for testing a GraphQL application: you'd have to prepare request dict every time, and you'll get errors +reported as JSON. + +Apiens provides a [GraphQLClientMixin](apiens/tools/graphql/testing/test_client_api.py) mixin for your test class +that adds methods for executing GraphQL requests through FastAPI's test client's `post()`, +and even uses a few tricks to make sure that you can get the original Python exception +(rather than formatter exception JSON): + +```python +# tests/conftest.py +from app.expose.graphql.schema import app_schema +from apiens.tools.graphql.testing import test_client_api + + +# FastAPI test client, with a mixin that supports GraphQL requests +class ApiClient(test_client_api.GraphQLClientMixin, TestClient): + GRAPHQL_ENDPOINT = '/graphql/' + +@pytest.fixture() +def api_client() -> ApiClient: + """ Test client for FastAPI, with GraphQL capabilities """ + with ApiClient(asgi_app) as c: + yield c +``` + +Here's how this client is used: + +```python +# tests/test_hello.py + +def test_hello_api(api_client: ApiClient): + """ Test hello() on FastAPI GraphQL endpoint """ + #language=GraphQL + q_hello = """ + query { + hello + } + """ + + res = api_client.graphql_sync(q_hello) + assert res['hello'] == 'Welcome' +``` + + + + + + + + + + + +Special Tools +============= + +We've built a FastAPI-GraphQL application with the tools from Apiens. +As you see, it's not a framework, but a set of tools that address specific issues to make your API +a sweet solution to use :) + +The rest of the document will describe specific tools that you may find useful here and there. +Let's first see more unit-testing tools. + + + + + + +## Unit-Testing Tools + + + + + + +### Network Gag +Your application likely uses some network services, and it's important to make sure +that in your unit-tests they all are properly mocked. That is, to make sure that your unit-tests +make no real network connections! + +The [network_gag](apiens/testing/network_gag.py) provides a decorator (and a context manager) +that makes sure that your code does no network connections through urllib, aiohttp, or amazon client: + +```python +with network_gag(): + ... # do your stuff without networking +``` + +If your code attempts to communicate with the Internet, the gag would give you an exception: + +> This unit-test has attempted to communicate with the Internet +> URL: http://service.mock/api/method +> Please use the `responses` library to mock HTTP in your tests. +> Cheers! + +With pytest it's more convenient to use [network_gag_conftest](apiens/testing/network_gag_conftest.py). +Just import this fixture into your `conftest.py`: + +```python +# conftest.py + +# Network gag +from apiens.testing.network_gag_conftest import stop_all_network, unstop_all_network +``` + +Now, if any unit-test attempts to communicate with the Internet, you'll get an exception. +However, if some specific test needs to allow networking, use this mark: + +```python +import pytest + +@pytest.mark.makes_real_network_connections +def test_something_with_networking(): + ... +``` + + + + + +### Object Match + +Lets see a situation where simple `assert result == value` is not enough. + +Suppose your unit-test verifies the response of some API: + +```python +res = execute_api_request() +assert res == { + 'user': { + 'id': 19, + 'login': 'kolypto', + 'name': 'Mark', + } +} +``` + +assertions work fine with static data like the `'login'` string there. +But this `'id'` is a dynamic value likely returned from some database and you cannot really +check equality like that. + +When a dynamic value is inserted into a static structure with nested fields, +developers have to inspect the response key by key: + +```python +res = execute_api_request() +assert res['user']['id'] > 0 # inspect the dynamic field +assert res['user']['login'] == 'kolypto' +assert res['user']['name'] == 'Mark' +``` + +or modify the response to keep it static: + +```python +res = execute_api_request() +user_id = res['user'].pop('id') # pop the dynamic field +assert res == { + 'user': { + 'login': 'kolypto', + 'name': 'Mark', + } +} +assert user_id > 0 +``` + +You've done this before, have you? :) + +Apiens offers several ways to unit-test dynamic values within complex structures. + +In some cases, you don't really care which exact value is there. You just want to ignore it. +Use the [`Whatever`](apiens/testing/object_match/okok.py) object: when compared to anything, +it gives a `True`: + +```python +res = execute_api_request() +assert res == { + 'user': { + # Ignore the value: equality always give True + 'id': Whatever, + 'login': 'kolypto', + 'name': 'Mark', + } +} +``` + +If you do actually care which value is there and want to inspect it, +use [check()](apiens/testing/object_match/check.py) with a lambda function that will perform +the test when the two values are compared to one another: + +```python +res = execute_api_request() +assert res == { + 'user': { + # Use a lambda function to test the value + 'id': check(lambda v: v>0), + 'login': 'kolypto', + 'name': 'Mark', + } +} +``` + +If you actually want to use the nested value in some more complicated context, you can actually +*capture the value* using [Parameter()](apiens/testing/object_match/parameter.py): +this object captures the value while being compared to it: + +```python +res = execute_api_request() +assert res == { + 'user': { + # Capture the value into a variable + 'id': (user_id := Parameter()), + 'login': 'kolypto', + 'name': 'Mark', + } +} + +# Use the value +assert user_id.value > 0 +print(user_id.value) +``` + +If your trouble is not about one value but rather about ignoring a whole bunch of dict keys, +use [`DictMatch`](apiens/testing/object_match/dict_match.py) for partial dict matching: + +```python +res = execute_api_request() +assert res == { + # Partial dict match: only named keys are compared + 'user': DictMatch({ + 'login': 'kolypto', + 'name': 'Mark', + }) +} +``` + +There also is [`ObjectMatch`](apiens/testing/object_match/object_match.py) for parial object matching: +it only inspects the attributes that you've named, ignoring all the rest: + +```python +# Create an object with some attributes +from collections import namedtuple +Point = namedtuple('Point', ('x', 'y')) +point = Point(0, 100) + +# Only inspect the 'x' attribute +assert point == ObjectMatch(x=0) +``` + + + + + + +### Model Match +This module will help you test that your database models actually match your GraphQL definitions +and your Pydantic validation schemas. Turns out, it's so easy to make a typo in field names, +underscores, camel cases, and especially, nullable and non-nullable fields! + +Suppose you have to models: a SqlAlchemy database model of a `User`: + +```python +class User(Base): + id = sa.Column(sa.Integer, primary_key=True) + login = sa.Column(sa.String) + password = sa.Column(sa.String) +``` + +and some Pydantic representation of this model, with one field missing: + +```python +class UserSchema(pd.BaseModel): + id: int + login: Optional[str] + # password: not included! +``` + +Here's how you can compare such models to make sure you've made no typos. +First, convert every model to some intermediate shape for matching +using [`model_match.match()`](apiens/testing/model_match/match.py): + +```python +# Convert every model to its intermediate shape +db_user = model_match.match(User) +pd_user = model_match.match(UserSchema) + +print(str(db_user)) +# -> id: !nullable required ; +# -> login: !required nullable ; +# -> password: !required nullable +``` + +You can immediately compare the two models, but note the missing field. +We need to exclude it first. This is achieved using the `select_fields()` helper +that generates a new model: + +```python +# Compare DB `User` model to Pydantic `UserSchema` +assert pd_user == model_match.select_fields( + # Exclude a few fields from comparison + db_user, + model_match.exclude('password'), +) +``` + +Now, if during development someone adds a field to the database model, your unit-tests would fail +and remind the developer that they need to update their pydantic schemas as well. + +This is quite useful in large teams because keeping models up to date takes discipline, +and we humans always fail at discipline :) + +This [`model_match`](apiens/testing/model_match/) tool can also rewrite field names into, +say, camelCase, thus supporting your GraphQL models. +Have a look at the code: it has you covered. + + + + + + +## SqlAlchemy tools +This section of the docs is under development. +Please have a look at the [apiens.tools.sqlalchemy](apiens/tools/sqlalchemy/) module. + +Undocumented features include: + +* tools.sqlalchemy.testing, conftest +* tools.sqlalchemy.commit.commit.session_disable_commit, session_enable_commit, session_flush_instead_of_commit +* tools.sqlalchemy.commit.transaction.db_transaction +* tools.sqlalchemy.commit.save.db_flush(), db_save(), db_save_refresh(), session_safe_commit(), refresh_instances() +* tools.sqlalchemy.commit.expire.no_expire_on_commit(), commit_no_expire() +* tools.sqlalchemy.instance.instance_history_proxy +* tools.sqlalchemy.instance.modified_attrs: modified_attribute_names, modified_column_attribute_names +* tools.sqlalchemy.session.session_tracking, ssn_later +* tools.sqlalchemy.session.ssn_later +* tools.sqlalchemy.loadopt.raiseload_in_testing +* tools.sqlalchemy.types.enum +* error.converting.sqlalchemy + + + + + + +## Python Tools +This section of the docs is under development. +Please have a look at the [apiens.tools.python](apiens/tools/python/) module. + +Undocumented features include: + +* tools.python.lazy_init +* tools.python.named_exit_stack +* tools.python.threadpool + + + + + + +## Structuring Tools +This section of the docs is under development. +Please have a look at the [apiens.structure](apiens/structure/) module. + +Undocumented features include: + +* structure.titled enum +* structure.func.documented_errors +* structure.func.simple_function + + + + + + +## Pydantic Tools +This section of the docs is under development. +Please have a look at the [apiens.tools.pydantic](apiens/tools/pydantic/) module. + +Undocumented features include: + +* tools.pydantic.derive +* tools.pydantic.partial + + + + + + +## Web Tools +This section of the docs is under development. +Please have a look at the [apiens.tools.web](apiens/tools/web/) module. + +Undocumented features include: + +* tools.web.jwt_token +* tools.web.shortid + + + + + + +## Advanced GraphQL Tools +This section of the docs is under development. +Please have a look at the [apiens.tools.graphql](apiens/tools/graphql/) module. + +Undocumented features include: + +* tools.graphql.directives.* +* tools.graphql.resolver.resolve +* tools.graphql.scalars.date +* tools.graphql.schema.ast +* tools.graphql.schema.input_types +* tools.graphql.directives.* + + + + + + +## Advanced FastAPI Tools +This section of the docs is under development. +Please have a look at the [apiens.tools.fastapi](apiens/tools/fastapi/) module. + +Undocumented features include: + +* tools.fastapi.class_based_view + + + + + + +## Advanced Ariadne Tools +This section of the docs is under development. +Please have a look at the [apiens.tools.ariadne](apiens/tools/ariadne/) module. + +Undocumented features include: + +* tools.ariadne.directive +* tools.ariadne.errors +* tools.ariadne.resolver +* tools.ariadne.scalars +* tools.ariadne.schema +* tools.ariadne.testing + + + + + + +CRUD APIs +========= +This section of the docs is under development. +Please have a look at the [apiens.tools.crud](apiens/crud/) module. + +Undocumented features include: + +* crud.query +* crud.mutate +* crud.signals +* crud.settings +* error: converting jessiql, converting apiens +``` diff --git a/apiens/error/base.py b/apiens/error/base.py index 2974b3a..67ca8c8 100644 --- a/apiens/error/base.py +++ b/apiens/error/base.py @@ -1,3 +1,8 @@ +""" Base Application errors + +Its subclasses are meant to be returned to the end user. +""" + from typing import ClassVar, Optional from .error_object.python import ErrorObject @@ -107,7 +112,7 @@ def format(cls, error: str, fixit: str = None, **info): @property def name(self): - """ Name of the exception class """ + """ Name of the exception class: error codename """ return self.__class__.__name__ def headers(self, headers: dict): diff --git a/apiens/error/converting/exception.py b/apiens/error/converting/exception.py index b396de0..c5f2af5 100644 --- a/apiens/error/converting/exception.py +++ b/apiens/error/converting/exception.py @@ -11,7 +11,9 @@ def converting_unexpected_errors(*, exc=exc): """ Convert unexpected Python exceptions into a human-friendly F_UNEXPECTED_ERROR Application Error This function is a catch-all: every expected error should be an instance of `exc.BaseApplicationError`. - Every other Python error is considered to be unexpected and wrapped into an `exc.F_UNEXPECTED_ERROR` + Every other Python error is considered to be unexpected and wrapped into an `exc.F_UNEXPECTED_ERROR`. + + If the exception defines the `default_api_error()` method, the method is used to convert it into a different error (!) Raises: exc.F_UNEXPECTED_ERROR: for unexpected Python errors diff --git a/apiens/error/error_object/pydantic.py b/apiens/error/error_object/pydantic.py index b476b8e..02049f8 100644 --- a/apiens/error/error_object/pydantic.py +++ b/apiens/error/error_object/pydantic.py @@ -1,4 +1,4 @@ -""" Pydantic definitions for the Error Object (Typed Dict) """ +""" Pydantic definitions for the Error Object (to be used with FastAPI) """ from typing import Optional diff --git a/apiens/error/error_object/python.py b/apiens/error/error_object/python.py index 318acfe..6eeb32e 100644 --- a/apiens/error/error_object/python.py +++ b/apiens/error/error_object/python.py @@ -1,4 +1,4 @@ -""" Python definitions for the Error Object (Typed Dict) """ +""" TypedDict definitions for the Error Object """ from __future__ import annotations diff --git a/apiens/error/error_object/schema.graphql b/apiens/error/error_object/schema.graphql index 3c56dc8..1be78f1 100644 --- a/apiens/error/error_object/schema.graphql +++ b/apiens/error/error_object/schema.graphql @@ -48,11 +48,11 @@ type ErrorObject { Example: - E_NOT_FOUND: - error: 'Cannot find User by id' - fixit: 'Have you entered the correct id?' - info: - object: 'User' + E_NOT_FOUND: + error: 'Cannot find User by id' + fixit: 'Have you entered the correct id?' + info: + object: 'User' """ info: Object! diff --git a/apiens/error/exc.py b/apiens/error/exc.py index 1b037f4..e8e2e46 100644 --- a/apiens/error/exc.py +++ b/apiens/error/exc.py @@ -307,8 +307,11 @@ class F_NOT_IMPLEMENTED(BaseApplicationError): # endregion -def export_error_catalog(globals: dict[str, Union[type[BaseApplicationError], Any]] = globals()): - """ Get a list of every BaseApplicationError defined in `globals` """ +def export_error_catalog(globals: dict[str, Union[type[BaseApplicationError], Any]] = globals()) -> list[type[BaseApplicationError]]: + """ Get a list of every BaseApplicationError defined in `globals` + + Use this function to export your list of errors as HTTP JSON API. + """ return [ value for name, value in globals.items() diff --git a/apiens/testing/model_match/__init__.py b/apiens/testing/model_match/__init__.py index 9f5376f..be72359 100644 --- a/apiens/testing/model_match/__init__.py +++ b/apiens/testing/model_match/__init__.py @@ -1,3 +1,17 @@ +""" Model Match compares models from different libraries to one another + +For instance, in your API application you may need to make sure that your +Pydantic schemas match your SqlAlchemy models, and they in turn match +your GraphQL objects. +This matching will help you find typos, especially when it comes to +making sure that your fields are consistently nullable or non-nullable. + +Turns out, it's so easy to make a typo. +This module saves you the pain. + + +""" + from .match import match from .predicates import include_only, exclude diff --git a/apiens/testing/network_gag.py b/apiens/testing/network_gag.py index 46d2661..ef05508 100644 --- a/apiens/testing/network_gag.py +++ b/apiens/testing/network_gag.py @@ -20,6 +20,10 @@ def network_gag(): Such a failure would tell the developer that their unit-test is not mocked properly. It stops network connections made by: urllib, urllib3, aiohttp, amazon client + + Example: + with network_gag(): + ... # do your stuff without networking """ # Network gag: Amazon # Because some of our tests use Amazon, we put a show stopper here that fails in that case diff --git a/apiens/testing/object_match/__init__.py b/apiens/testing/object_match/__init__.py index cb889b7..6d52b38 100644 --- a/apiens/testing/object_match/__init__.py +++ b/apiens/testing/object_match/__init__.py @@ -1,6 +1,10 @@ -from .object_match import ObjectMatch -from .dict_match import DictMatch +""" Unit-testing tools for testing values of complex objects """ + from .parameter import Parameter -from .unsorted import unsorted, runsorted, kunsorted +from .dict_match import DictMatch from .check import check from .okok import Whatever + +from .object_match import ObjectMatch + +from .unsorted import unsorted, runsorted, kunsorted diff --git a/apiens/testing/object_match/check.py b/apiens/testing/object_match/check.py index a7dfa10..b237c8a 100644 --- a/apiens/testing/object_match/check.py +++ b/apiens/testing/object_match/check.py @@ -1,3 +1,5 @@ +""" Check: use functions to test nested values of an object """ + from collections import abc from typing import Any diff --git a/apiens/testing/object_match/dict_match.py b/apiens/testing/object_match/dict_match.py index e2c2209..d7c82f2 100644 --- a/apiens/testing/object_match/dict_match.py +++ b/apiens/testing/object_match/dict_match.py @@ -1,3 +1,5 @@ +""" DictMatch: test a dictionary for a partial match """ + from collections import abc diff --git a/apiens/testing/object_match/object_match.py b/apiens/testing/object_match/object_match.py index e5bd2d9..b5e90f8 100644 --- a/apiens/testing/object_match/object_match.py +++ b/apiens/testing/object_match/object_match.py @@ -1,3 +1,5 @@ +""" ObjectMatch: test an object for a partial match """ + class ObjectMatch: """ Unit test helper: an object for == comparisons with other objects field by field. diff --git a/apiens/testing/object_match/okok.py b/apiens/testing/object_match/okok.py index b6c00ce..1ddcec9 100644 --- a/apiens/testing/object_match/okok.py +++ b/apiens/testing/object_match/okok.py @@ -1,4 +1,4 @@ -""" OkOk helpers for unit-tests: okay, okay! we're equal! """ +""" Ok Ok: accept whatever values in unit-tests. """ class _Whatever: diff --git a/apiens/testing/object_match/parameter.py b/apiens/testing/object_match/parameter.py index c4dac6f..2a7bda6 100644 --- a/apiens/testing/object_match/parameter.py +++ b/apiens/testing/object_match/parameter.py @@ -1,3 +1,5 @@ +""" Parameter: capture a value of a nested field in a complex object """ + class Parameter: """ Grab a parameter during comparison: e.g. a dynamic primary key diff --git a/apiens/testing/object_match/unsorted.py b/apiens/testing/object_match/unsorted.py index 264c660..a73ca1d 100644 --- a/apiens/testing/object_match/unsorted.py +++ b/apiens/testing/object_match/unsorted.py @@ -1,3 +1,8 @@ +""" Unsorted: test lists where items change their positions + +This often happens when the back-end returns a set() converted into a list() +""" + from collections import abc from operator import itemgetter diff --git a/apiens/tools/ariadne/schema/load.py b/apiens/tools/ariadne/schema/load.py index 36ef9ea..25bf67e 100644 --- a/apiens/tools/ariadne/schema/load.py +++ b/apiens/tools/ariadne/schema/load.py @@ -1,4 +1,10 @@ -""" Ariadne: tools for putting a schema together """ +""" Ariadne: tools for putting a schema together + +Features: + +* Load *.graphql files +* Import Ariadne definitions from a Python file +""" import os.path from types import ModuleType diff --git a/apiens/tools/ariadne/testing/test_client.py b/apiens/tools/ariadne/testing/test_client.py index 551b8e0..590f27f 100644 --- a/apiens/tools/ariadne/testing/test_client.py +++ b/apiens/tools/ariadne/testing/test_client.py @@ -1,3 +1,13 @@ +""" Test client for Ariadne applications. + +This client offers greater flexibility than the FastAPI GraphQL client because: + +* It involves fewer application layers and works faster +* You can customize the context before executing any operations +* Errors are reported as Exceptions (rather than JSON objects) and can be analyzed +* Supports subscriptions :) +""" + from __future__ import annotations import graphql diff --git a/apiens/tools/fastapi/exception_handlers.py b/apiens/tools/fastapi/exception_handlers.py index dfa5905..d34b4f1 100644 --- a/apiens/tools/fastapi/exception_handlers.py +++ b/apiens/tools/fastapi/exception_handlers.py @@ -113,7 +113,7 @@ async def http_404_handler_with_route_suggestions(request: Request, e: HTTPExcep # In FastAPI, there is no such thing yet as "current route". It's not stored in the request. # Therefore, we can't tell whether this 404 come from a view or from the router itself. - # So this "not found" page is + # So this "not found" page is detected using error message analysis :( if e.status_code == status.HTTP_404_NOT_FOUND and e.detail == 'Not Found': close_matches = suggest_api_endpoint(request.app, request.scope['method'], request.scope['path']) diff --git a/apiens/tools/graphql/errors/human_readable.py b/apiens/tools/graphql/errors/human_readable.py index c9eb17c..c8c47e3 100644 --- a/apiens/tools/graphql/errors/human_readable.py +++ b/apiens/tools/graphql/errors/human_readable.py @@ -1,14 +1,15 @@ -""" Tools that enable rich user-friendly validation of input data - -Usage: - from apiens.tools.ariadne.schema import load_schema_from_module - import apiens.tools.ariadne.scalars.date - schema = ariadne.make_executable_schema([ - ariadne.load_schema_from_path(os.path.dirname(__file__)), - load_schema_from_module(apiens.tools.ariadne, 'rich_validation.graphql'), - ], - apiens.tools.ariadne.scalars.date.definitions, - ) +""" Make GraphQL errors more human-readable. + +This module improves the way Int, Float and Bool objects report errors. +The new error message is human-readable and can be reported to the end-user. + +Before: + +> 'message': "Int cannot represent non-integer value: 'INVALID'", + +After: + +> 'message': "Not a valid number", # Improved, human-readable """ from typing import Any diff --git a/apiens/tools/graphql/middleware/documented_errors.py b/apiens/tools/graphql/middleware/documented_errors.py index d0ed961..b3b0ec4 100644 --- a/apiens/tools/graphql/middleware/documented_errors.py +++ b/apiens/tools/graphql/middleware/documented_errors.py @@ -28,7 +28,8 @@ def documented_errors_middleware(*, ignore_errors: frozenset[str] = DEFAULT_IGNO Errors: E_AUTH_REQUIRED: you must be signed in in order to use this API. - When an error is raised, this middleware would check whether docstring has it covered. + When an error is raised, this middleware would check whether the docstring of the field, + or the docstring of the parent object, mentions this error by name. If not, an UndocumentedError is raised instead. NOTE: it's an async middleware. It won't work with GraphQL running in sync mode (i.e. using graphql_sync())! diff --git a/apiens/tools/graphql/testing/test_client.py b/apiens/tools/graphql/testing/test_client.py index 2a00aa4..24df821 100644 --- a/apiens/tools/graphql/testing/test_client.py +++ b/apiens/tools/graphql/testing/test_client.py @@ -1,4 +1,12 @@ -""" Test Client for the GraphQL app """ +""" Test Client for the GraphQL app + +This client offers greater flexibility than the FastAPI GraphQL client because: + +* It involves fewer application layers and works faster +* You can customize the context before executing any operations +* Errors are reported as Exceptions (rather than JSON objects) and can be analyzed +* Supports subscriptions :) +""" from __future__ import annotations import asyncio diff --git a/apiens/tools/settings/__init__.py b/apiens/tools/settings/__init__.py index 962794b..1ee7a65 100644 --- a/apiens/tools/settings/__init__.py +++ b/apiens/tools/settings/__init__.py @@ -1,15 +1,25 @@ """ Helpers for your app configuration +Features: + +* Supports three modes: production, development, testing +* Loads configuration from environment variables +* Automatically switches to "testing" when `pytest` is used + Example: - class Settings(apiens.tools.settings.Settings): + import pydantic as pd + + class Settings(pd.Settings): REDIS_URL: str set_default_environment('ENV', default_environment='dev') load_environment_defaults_for('ENV') switch_environment_when_running_tests('ENV') + logging.basicConfig() settings = Settings() +Check out `mixins` for some configuration values' recipes. """ from .defs import Env @@ -19,13 +29,14 @@ class Settings(apiens.tools.settings.Settings): get_environment, ) from .env_test import switch_environment_when_running_tests -from . import mixins +from . import mixins, logging # Syntactic sugar to indicate values that will get defaults when not provided AUTOMATIC = None +# When `pint` is available, we can use `unit` try: from .unit import unit except ImportError as e: diff --git a/apiens/tools/settings/env.py b/apiens/tools/settings/env.py index a17fc6e..9a4ca6b 100644 --- a/apiens/tools/settings/env.py +++ b/apiens/tools/settings/env.py @@ -1,3 +1,11 @@ +""" Loads configuration values from the environment + +Provides the following features: + +* Use an environment variable `VAR_NAME` to choose the current environment (dev, prod, test) +* Load configuration values from `.env` files under `misc/env` and `misc/env.local` +* Load some additional values from `misc/env.local` when running locally (not in Docker) +""" from __future__ import annotations import os.path @@ -9,19 +17,20 @@ # Environment enum class ENV_ENUM = Env -# Path to .env files for environments +# Path to .env files for environments: dev.env, prod.env, test.env ENVS_PATH = 'misc/env/' -# Path to .env files when running locally (as determined by the following function) +# Path to .env files when running locally (not in Docker) +# See `IS_RUNNING_LOCALLY` ENVS_LOCAL_PATH = 'misc/env.local/' -# Is the application running locally? i.e. not in Docker. -# If so, then `ENVS_LOCAL_PATH` will be loaded too +# Is the application running locally (i.e. not in Docker)? +# When running locally, will load configuration from `misc/env.local` IS_RUNNING_LOCALLY = int(os.getenv('ENV_RUNNING_LOCALLY', '0')) def set_default_environment(VAR_NAME: str, *, default_environment: str): - """ Set the default app environment if not set + """ Set the default app environment, if not set already. Example: set_default_environment('ENV', default_environment='dev') @@ -33,12 +42,11 @@ def set_default_environment(VAR_NAME: str, *, default_environment: str): def load_environment_defaults_for(VAR_NAME: str): """ Load .env files for the environment if not already loaded. - The app reads its configuration from the environment. - But in some cases, like debugging with an IDE, this is inconvenient. - - For this reason, we *also* load .env files with Python, but it never overrides the existing environment. + It will load values from `.env` files into the environment: - This is a back-up method, not the primary one. + * /misc/env/{name}.env + * /misc/env.local/{name}.env (only when running locally) + * /.{name}.env (only when running locally) """ # Current environment env = get_environment(VAR_NAME) @@ -58,12 +66,12 @@ def load_environment_from_file(name: str, *, override: bool): override: Override existing environment variables? """ # Load from `misc/env` - dotenv.load_dotenv(dotenv.find_dotenv(os.path.join(ENVS_PATH, f'{name}.env')), override=override) + _load_dotenv_file(ENVS_PATH, f'{name}.env', override=override) # Load from `misc/env.local` (only if running locally) if IS_RUNNING_LOCALLY: - dotenv.load_dotenv(dotenv.find_dotenv(os.path.join(ENVS_LOCAL_PATH, f'{name}.env')), override=override) - dotenv.load_dotenv(dotenv.find_dotenv(f'.{name}.env'), override=override) + _load_dotenv_file(ENVS_LOCAL_PATH, f'{name}.env', override=override) + _load_dotenv_file('.', f'.{name}.env', override=override) def get_environment(VAR_NAME: str) -> Env: @@ -77,3 +85,9 @@ def get_environment(VAR_NAME: str) -> Env: """ env: str = os.environ[VAR_NAME] return ENV_ENUM(env) + + +def _load_dotenv_file(path: str, filename: str, *, override: bool): + """ Load a .env file in path/filename, if it exists """ + file_path = dotenv.find_dotenv(os.path.join(path, filename)) + dotenv.load_dotenv(file_path, override=override) diff --git a/apiens/tools/settings/env_test.py b/apiens/tools/settings/env_test.py index 6bb73b9..5207102 100644 --- a/apiens/tools/settings/env_test.py +++ b/apiens/tools/settings/env_test.py @@ -1,3 +1,5 @@ +""" Automatically switch to "testing" mode when a test runner (pytest) is used """ + from __future__ import annotations import os diff --git a/apiens/tools/settings/logging.py b/apiens/tools/settings/logging.py index 00256ab..cb91d13 100644 --- a/apiens/tools/settings/logging.py +++ b/apiens/tools/settings/logging.py @@ -1,3 +1,8 @@ +""" Default logging configuration for your convenience + +The default logging configuration in Python +""" + import logging.config diff --git a/apiens/tools/settings/mixins.py b/apiens/tools/settings/mixins.py index adfa6b3..c4e173b 100644 --- a/apiens/tools/settings/mixins.py +++ b/apiens/tools/settings/mixins.py @@ -18,7 +18,12 @@ class EnvMixin(pd.BaseSettings): """ Settings: environment. - Lets your app run in different modes: e.g. provide more error information when in development + Lets your app run in different modes: e.g. provide more error information when in development. + + Example: + + if not settings.is_production: + ... # do some debugging stuff """ # Environment ENV: Env @@ -115,7 +120,10 @@ class SecretMixin(pd.BaseSettings): class PostgresMixin(pd.BaseSettings): - """ Setting: Postgres connection """ + """ Setting: Postgres connection + + Variable names are the same as those used with the official Docker container. + """ # Database connection # Names of these variables match with names from the postgres Docker container. # We manually set `env=` name to make sure that Config.env_prefix has no effect on it @@ -144,15 +152,3 @@ class RedisMixin(pd.BaseSettings): """ Setting: Redis connection """ REDIS_URL: pd.RedisDsn - -# Example: - - -class ExampleSettings(EnvMixin, LocaleMixin, DomainMixin, CorsMixin, SecretMixin, PostgresMixin, RedisMixin, pd.BaseSettings): - # Human-readable name of this project - # Used in titles & emails & stuff - PROJECT_NAME: str = 'Cat Image Gallery' - - class Config: - env_prefix = 'CATS_' - case_sensitive = True diff --git a/apiens/tools/sqlalchemy/testing/__init__.py b/apiens/tools/sqlalchemy/testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/misc/readme-app/README.md b/misc/readme-app/README.md new file mode 100644 index 0000000..ac69f80 --- /dev/null +++ b/misc/readme-app/README.md @@ -0,0 +1,8 @@ +Demo Application +================ + +The purpose of this application is to demonstrate how Apiens works. +All the the code that you see in the main `README.md` file is tested here. + +It also has unit-tests. +Yes, we will have a unit-tested README. diff --git a/misc/readme-app/app/__init__.py b/misc/readme-app/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/misc/readme-app/app/exc.py b/misc/readme-app/app/exc.py new file mode 100644 index 0000000..1cdb406 --- /dev/null +++ b/misc/readme-app/app/exc.py @@ -0,0 +1,68 @@ +import apiens.error.exc + +# Base class for Application Errors +from apiens.error import BaseApplicationError + + +# region: 400 Client Errors +from apiens.error.exc import ( + E_API_ARGUMENT, + E_API_ACTION, + E_CLIENT_VALIDATION, +) + +# endregion + +# region: 401 Unauthorized + +from apiens.error.exc import ( + E_AUTH_REQUIRED, + F_AUTH_FAILED, + E_AUTH_CREDENTIALS, + E_AUTH_USER_DEACTIVATED, + E_AUTH_USER_PASSWORD_EXPIRED, +) + +# endregion + +# region: 403 Access Denied + +from apiens.error.exc import ( + E_FORBIDDEN, + E_ROLE_REQUIRED, + E_PERMISSION_REQUIRED, +) + +# endregion + +# region: 404 Not Found + +from apiens.error.exc import ( + E_NOT_FOUND, +) + +# endregion + +# region: 409 Conflict + +from apiens.error.exc import ( + E_CONFLICT, + E_CONFLICT_DUPLICATE, +) + +# endregion + +# region: 500 Server Errors + +from apiens.error.exc import ( + F_FAIL, + F_UNEXPECTED_ERROR, + F_NOT_IMPLEMENTED, +) + +# endregion + + +def export_error_catalog() -> list[type[BaseApplicationError]]: + """ Get the list of all defined errors """ + return apiens.error.exc.export_error_catalog(globals=globals()) diff --git a/misc/readme-app/app/expose/__init__.py b/misc/readme-app/app/expose/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/misc/readme-app/app/expose/cli/__init__.py b/misc/readme-app/app/expose/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/misc/readme-app/app/expose/cli/main.py b/misc/readme-app/app/expose/cli/main.py new file mode 100644 index 0000000..53e6575 --- /dev/null +++ b/misc/readme-app/app/expose/cli/main.py @@ -0,0 +1,9 @@ +import typer +from importlib import import_module + +app = typer.Typer(no_args_is_help=True) + +app.add_typer(import_module('app.expose.cli.run').app, name='run') + +if __name__ == '__main__': + app() diff --git a/misc/readme-app/app/expose/cli/run.py b/misc/readme-app/app/expose/cli/run.py new file mode 100644 index 0000000..d221e27 --- /dev/null +++ b/misc/readme-app/app/expose/cli/run.py @@ -0,0 +1,28 @@ +import typer + +app = typer.Typer( + help='Run application', + no_args_is_help=True, +) + + +@app.command() +def uvicorn(host: str = '0.0.0.0', port: int = 5000): + """ Run the application """ + import uvicorn + from app.globals.config import settings + + # Alternatively: + # $ uvicorn module:app --reload + + uvicorn.run( + 'app.expose.fastapi.app:asgi_app', + host=host, + port=port, + debug=not settings.is_production, + reload=not settings.is_production, + reload_dirs=[ + # Only watch specific directories. We have too many files in CWD + 'misc', + ], + ) diff --git a/misc/readme-app/app/expose/fastapi/__init__.py b/misc/readme-app/app/expose/fastapi/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/misc/readme-app/app/expose/fastapi/app.py b/misc/readme-app/app/expose/fastapi/app.py new file mode 100644 index 0000000..5b117b8 --- /dev/null +++ b/misc/readme-app/app/expose/fastapi/app.py @@ -0,0 +1,43 @@ +from fastapi import FastAPI + +from app.globals.config import settings + + +# ASGI app +asgi_app = FastAPI( + title=settings.PROJECT_NAME, + description=""" Test app """, + debug=not settings.is_production, +) + +# Middleware +if not settings.is_testing: + from fastapi.middleware.cors import CORSMiddleware + from fastapi.middleware.trustedhost import TrustedHostMiddleware + + # All requests must have correct "Host:" header (to guard against HTTP Host Header attack) + asgi_app.add_middleware(TrustedHostMiddleware, allowed_hosts=[settings.DOMAIN, f'*.{settings.DOMAIN}']) + + # CORS requests (when the UI runs on a host different from the API) + asgi_app.add_middleware( + CORSMiddleware, + allow_origins=[str(origin) for origin in settings.CORS_ORIGINS], + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], + ) + + +# Attach routes +from . import endpoints +asgi_app.include_router(endpoints.router) + + +# Attach GraphQL routes +from app.expose.graphql.app import graphql_app +asgi_app.mount('/graphql/', graphql_app) + + +# # Exception handlers +from apiens.tools.fastapi.exception_handlers import register_application_exception_handlers +register_application_exception_handlers(asgi_app, passthru=settings.is_testing) diff --git a/misc/readme-app/app/expose/fastapi/endpoints.py b/misc/readme-app/app/expose/fastapi/endpoints.py new file mode 100644 index 0000000..84c6147 --- /dev/null +++ b/misc/readme-app/app/expose/fastapi/endpoints.py @@ -0,0 +1,22 @@ +import fastapi +from app import exc + + +router = fastapi.APIRouter() + +@router.get('/unexpected_error') +def view_unexpected_error(): + raise RuntimeError('Fail') + + +@router.get('/app_error') +def view_app_error(): + raise exc.E_NOT_FOUND( + # Error: the negative message + "User not found by email", + # Fixit: the positive message + "Please check the provided email", + # additional information + object='User', + email='user@example.com', + ) \ No newline at end of file diff --git a/misc/readme-app/app/expose/graphql/__init__.py b/misc/readme-app/app/expose/graphql/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/misc/readme-app/app/expose/graphql/app.py b/misc/readme-app/app/expose/graphql/app.py new file mode 100644 index 0000000..edc4a30 --- /dev/null +++ b/misc/readme-app/app/expose/graphql/app.py @@ -0,0 +1,34 @@ +from ariadne.asgi import GraphQL +from apiens.tools.graphql.middleware.documented_errors import documented_errors_middleware +from apiens.tools.graphql.middleware.unexpected_errors import unexpected_errors_middleware +from apiens.tools.ariadne.errors.format_error import application_error_formatter + +from app.globals.config import settings +from app import exc +from .schema import app_schema + + +# Init the ASGI application +graphql_app = GraphQL( + # The schema to execute operations against + schema=app_schema, + # The context value. None yet. + context_value=None, + # Error formatter presents Application Errors as proper JSON Error Objects + error_formatter=application_error_formatter, + # Developer features are only available when not in production + introspection=not settings.is_production, + debug=not settings.is_production, + # Some middleware + middleware=[ + # This middleware makes sure that every application error is documented. + # That is, if `E_NOT_FOUND` can be returned by your `getUserById`, + # then its docstring should contain something like this: + # > Errors: E_NOT_FOUND: the user is not found + documented_errors_middleware(exc=exc), + + # Converts every Python exception into F_UNEXPECTED_ERROR. + # Users `converting_unexpected_errors()` + unexpected_errors_middleware(exc=exc), + ], +) \ No newline at end of file diff --git a/misc/readme-app/app/expose/graphql/context.py b/misc/readme-app/app/expose/graphql/context.py new file mode 100644 index 0000000..3af9b90 --- /dev/null +++ b/misc/readme-app/app/expose/graphql/context.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +import graphql + + +# Override ResolveInfo type annotations +class ResolveInfo(graphql.GraphQLResolveInfo): + context: RequestContext + + +class RequestContext: + """ Out custom context """ diff --git a/misc/readme-app/app/expose/graphql/query.py b/misc/readme-app/app/expose/graphql/query.py new file mode 100644 index 0000000..162004e --- /dev/null +++ b/misc/readme-app/app/expose/graphql/query.py @@ -0,0 +1,34 @@ +import graphql +import ariadne + +from apiens.tools.graphql.resolver.resolver_marker import ( + resolves_nonblocking, + resolves_in_threadpool, +) +from app.expose.graphql.context import ResolveInfo + + +# Prepare the root Query +Query = ariadne.QueryType() + +@Query.field('hello') +@resolves_nonblocking +def resolve_hello(_, info: ResolveInfo): + return 'Welcome' + + +@Query.field('unexpected_error') +@resolves_nonblocking +def resolve_unexpected_error(_, info: ResolveInfo): + raise RuntimeError('Fail') + + +from app import exc + +@Query.field('app_error') +@resolves_nonblocking +def resolve_app_error(_, info: ResolveInfo): + raise exc.E_API_ACTION( + error="Something went wrong", + fixit="Please try again", + ) \ No newline at end of file diff --git a/misc/readme-app/app/expose/graphql/scalars.graphql b/misc/readme-app/app/expose/graphql/scalars.graphql new file mode 100644 index 0000000..c2763a0 --- /dev/null +++ b/misc/readme-app/app/expose/graphql/scalars.graphql @@ -0,0 +1 @@ +scalar Object diff --git a/misc/readme-app/app/expose/graphql/schema.graphql b/misc/readme-app/app/expose/graphql/schema.graphql new file mode 100644 index 0000000..279e116 --- /dev/null +++ b/misc/readme-app/app/expose/graphql/schema.graphql @@ -0,0 +1,15 @@ +type Query { + hello: String! + + """ Example error: unexpected Python error """ + unexpected_error: String + + """ Example error: raised application error + + Errors: + E_API_ACTION: Always reports this error + """ + app_error: String + + list_users(first: Int): String +} \ No newline at end of file diff --git a/misc/readme-app/app/expose/graphql/schema.py b/misc/readme-app/app/expose/graphql/schema.py new file mode 100644 index 0000000..ea43e93 --- /dev/null +++ b/misc/readme-app/app/expose/graphql/schema.py @@ -0,0 +1,27 @@ +import ariadne + +# Prepare definitions +from .query import Query +graphql_definitions: list[ariadne.SchemaBindable] = [ + Query, +] + +# GraphQL schema +import os + +import apiens.error.error_object +from apiens.tools.ariadne.schema.load import load_schema_from_module + +app_schema = ariadne.make_executable_schema([ + # Load all *.graphql files from this folder + ariadne.load_schema_from_path(os.path.dirname(__file__)), + # Load a *.graphql file from a module + load_schema_from_module(apiens.error.error_object, 'schema.graphql'), + ], + graphql_definitions, + ariadne.snake_case_fallback_resolvers, +) + +# Improve error messages from scalars like Int and Float +from apiens.tools.graphql.errors import human_readable +human_readable.install_types_to_schema(app_schema) diff --git a/misc/readme-app/app/globals/__init__.py b/misc/readme-app/app/globals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/misc/readme-app/app/globals/config.py b/misc/readme-app/app/globals/config.py new file mode 100644 index 0000000..f1f6da9 --- /dev/null +++ b/misc/readme-app/app/globals/config.py @@ -0,0 +1,46 @@ +""" Application configuration + +Lives in module at its top level. Easy to import. +""" + +import pydantic as pd + +from apiens.tools.settings import AUTOMATIC +from apiens.tools.settings import unit +from apiens.tools.settings.mixins import * + + +# We're using Pydantic BaseSettings for validation. +# It also can read values from the environment. +class Settings(EnvMixin, DomainMixin, CorsMixin, PostgresMixin, pd.BaseSettings): + """ Application settings """ + + # Human-readable name of this project + # Used in titles, emails & stuff + PROJECT_NAME: str = 'My Application' + + class Config: + # Use this prefix for environment variable names. + env_prefix = 'APP_' + case_sensitive = True + + +# HACK: patch the path. +# This is necessary only because our project lives in a sub-folder, so "misc/env" cannot be easily found. +import os.path +import apiens.tools.settings +apiens.tools.settings.env.ENVS_PATH = os.path.realpath(os.path.join(os.path.dirname(__file__), '../../misc/env')) + + +# Load default environment values from .env files. +from apiens.tools.settings import ( + set_default_environment, + load_environment_defaults_for, + switch_environment_when_running_tests, +) +set_default_environment('APP_ENV', default_environment='dev') +load_environment_defaults_for('APP_ENV') +switch_environment_when_running_tests('APP_ENV') + +# Init settings +settings = Settings() diff --git a/misc/readme-app/app/globals/postgres.py b/misc/readme-app/app/globals/postgres.py new file mode 100644 index 0000000..6686691 --- /dev/null +++ b/misc/readme-app/app/globals/postgres.py @@ -0,0 +1,46 @@ +from collections import abc +import sqlalchemy as sa + +from apiens.tools.settings import unit + +# Import settings +from app.globals.config import settings + + +# Prepare some units for readability +min = unit('minute') +sec = unit('second') + +# Initialize SqlAlchemy Engine: the connection pool +engine: sa.engine.Engine = sa.create_engine( + # Use settings + settings.POSTGRES_URL, + future=True, + # Configure the pool of connections + pool_size=20, + max_overflow=10, + pool_pre_ping=True, + # Use `unit` to make the value more readable + pool_recycle=10 * min >> sec, +) + +# Initialize the SessionMaker: the way to get a SqlAlchemy Session +from sqlalchemy.orm import Session +SessionMakerFn = abc.Callable[[], Session] + +SessionMaker: SessionMakerFn = sa.orm.sessionmaker( + autocommit=False, + autoflush=False, + bind=engine, +) + + +# In testing, make sure that every connection is properly closed. +if settings.is_testing: + # Prepare a new SessionMaker that tracks every opened connection + from apiens.tools.sqlalchemy.session.session_tracking import TrackingSessionMaker + SessionMaker = TrackingSessionMaker(class_=SessionMaker.class_, **SessionMaker.kw) + + # Define a function for unit-tests: check that all Sessions were properly close()d + def assert_no_active_sqlalchemy_sessions(): + SessionMaker.assert_no_active_sessions() diff --git a/misc/readme-app/app/globals/redis.py b/misc/readme-app/app/globals/redis.py new file mode 100644 index 0000000..25180aa --- /dev/null +++ b/misc/readme-app/app/globals/redis.py @@ -0,0 +1,39 @@ +from redis import Redis, ConnectionPool + +from app.globals.config import settings + + +# Pool of Redis connections +connection_pool = ConnectionPool.from_url( + settings.REDIS_CONNECT_URL, + health_check_interval=60, +) + + +def get_redis() -> Redis: + """ Get a new Redis connection + + NOTE: don't forget to close() the connection! + + Example: + >>> with get_redis() as redis: + >>> ... + """ + return Redis(connection_pool=connection_pool) + + +# In testing, make sure that every connection is properly closed +if settings.is_testing: + # Track every open session + from apiens.tools.sqlalchemy.session.session_tracking import ActiveObjectsRegistry + active_sessions = ActiveObjectsRegistry(weak=False) + + _original_get_redis = get_redis + def get_redis() -> Redis: + redis = _original_get_redis() + active_sessions.add_and_decorate(redis, 'close') + return redis + + # Define a function for unit-tests: check that all Sessions were properly close()d + def assert_no_active_redis_sessions(): + active_sessions.assert_no_active_objects() diff --git a/misc/readme-app/app/models.py b/misc/readme-app/app/models.py new file mode 100644 index 0000000..efa7a92 --- /dev/null +++ b/misc/readme-app/app/models.py @@ -0,0 +1,22 @@ +import sqlalchemy as sa +import sqlalchemy.orm + +from sqlalchemy.ext.declarative import declarative_base + + +Base = declarative_base() + +class User(Base): + __tablename__ = 'users' + + id = sa.Column(sa.String, nullable=False, primary_key=True) + login = sa.Column(sa.String, nullable=False) + + +class Article(Base): + __tablename__ = 'articles' + + id = sa.Column(sa.String, nullable=False, primary_key=True) + title = sa.Column(sa.String, nullable=False) + + author_id = sa.Column(User.id.type, sa.ForeignKey(User.id), nullable=False) diff --git a/misc/readme-app/misc/env/dev.env b/misc/readme-app/misc/env/dev.env new file mode 100644 index 0000000..0cfa5a7 --- /dev/null +++ b/misc/readme-app/misc/env/dev.env @@ -0,0 +1,10 @@ +ENV=dev + +POSTGRES_HOST=localhost +POSTGRES_PORT=54321 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=test_apiens + +APP_SERVER_URL=http://localhost +APP_CORS_ORIGINS=http://localhost diff --git a/misc/readme-app/misc/env/test.env b/misc/readme-app/misc/env/test.env new file mode 100644 index 0000000..2662120 --- /dev/null +++ b/misc/readme-app/misc/env/test.env @@ -0,0 +1,10 @@ +ENV=test + +POSTGRES_HOST=localhost +POSTGRES_PORT=54321 +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=test_apiens + +APP_SERVER_URL=http://localhost +APP_CORS_ORIGINS=http://localhost diff --git a/misc/readme-app/tests/__init__.py b/misc/readme-app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/misc/readme-app/tests/conftest.py b/misc/readme-app/tests/conftest.py new file mode 100644 index 0000000..13c3f3e --- /dev/null +++ b/misc/readme-app/tests/conftest.py @@ -0,0 +1,77 @@ +from contextlib import contextmanager +import pytest + +from jessiql.testing.recreate_tables import ( + truncate_db_tables, + recreate_db_tables, + check_recreate_necessary, +) + +# Prepare DB +from app.globals.postgres import SessionMaker, Session + +@pytest.fixture() +def ssn(_prepare_clean_database) -> Session: + with SessionMaker() as ssn: + yield ssn + + +@pytest.fixture(autouse=True) +def _assert_no_active_sessions(): + """ Make sure that every session is properly closed """ + from app.globals.postgres import assert_no_active_sqlalchemy_sessions + assert_no_active_sqlalchemy_sessions() + + +@pytest.fixture(scope='session') +def _recreate_database_if_necessary(): + """ Prepare database structure """ + # Make sure we only destroy data in the test DB + assert 'test_apiens' == engine.url.database + + # Recreate if necessary + metadata = models.Base.metadata + if check_recreate_necessary(engine, metadata): + recreate_db_tables(engine, metadata) + + +@pytest.fixture() +def _prepare_clean_database(_recreate_database_if_necessary): + """ Prepare a working database for the application """ + truncate_db_tables(engine, models.Base.metadata) + +# Test client +from app.expose.fastapi.app import asgi_app +from app.expose.graphql.schema import app_schema + +from apiens.tools.fastapi.test_client import TestClient +from apiens.tools.graphql.testing import test_client, test_client_api + + +class ApiClient(test_client_api.GraphQLClientMixin, TestClient): + GRAPHQL_ENDPOINT = '/graphql/' + +@pytest.fixture() +def api_client() -> ApiClient: + """ Test client for FastAPI, with GraphQL capabilities """ + with ApiClient(asgi_app) as c: + yield c + +class GraphQLClient(test_client.GraphQLTestClient): + def __init__(self): + super().__init__(schema=app_schema, debug=True) + + # The GraphQL unit test needs to initialize its own context for the request. + @contextmanager + def init_context_sync(self): + yield {} + +@pytest.fixture() +def graphql_client() -> GraphQLClient: + """ Test client for GraphQL schema """ + with GraphQLClient() as c: + yield c + + +# Network gag +from apiens.testing.network_gag_conftest import stop_all_network, unstop_all_network diff --git a/misc/readme-app/tests/test_api.py b/misc/readme-app/tests/test_api.py new file mode 100644 index 0000000..32b2565 --- /dev/null +++ b/misc/readme-app/tests/test_api.py @@ -0,0 +1,14 @@ +from app.expose.graphql.schema import app_schema + + +def test_no_unmarked_resolvers(): + """ Test that every resolver is properly marked + + This test requires that developers put one of the following markers on their resolvers: + * resolves_in_threadpool() + * resolves_nonblocking() + * resolves_async() + """ + from apiens.tools.graphql.resolver.resolver_marker import assert_no_unmarked_resolvers + + assert_no_unmarked_resolvers(app_schema) diff --git a/misc/readme-app/tests/test_errors.py b/misc/readme-app/tests/test_errors.py new file mode 100644 index 0000000..280cc7e --- /dev/null +++ b/misc/readme-app/tests/test_errors.py @@ -0,0 +1,124 @@ +import pytest +from apiens.testing.object_match import Whatever +from .conftest import ApiClient + + +def test_fastapi_error(api_client: ApiClient): + # === Test: application error + res = api_client.get('/app_error') + assert res.json() == { + 'error': { + 'name': 'E_NOT_FOUND', + 'title': 'Not found', + "error": "User not found by email", + "fixit": "Please check the provided email", + 'httpcode': 404, + 'info': { + 'object': 'User', + 'email': 'user@example.com', + }, + 'debug': {}, + } + } + + # === Test: internal server error + with pytest.raises(RuntimeError): + res == api_client.get('/unexpected_error') + assert res.json() == { + #... not caught + } + + +def test_graphql_api_error(api_client: ApiClient): + """ Test how API reports errors """ + # === Test: application error + query = 'query { app_error }' + + res = api_client.graphql_sync(query) + assert res.graphql_error == { + 'message': "Something went wrong", + 'path': ['app_error'], + 'locations': Whatever, + 'extensions': { + # Application error info + 'error': { + 'name': 'E_API_ACTION', + 'httpcode': 400, + 'title': "Incorrect action", + 'error': "Something went wrong", + 'fixit': "Please try again", + 'info': {}, + 'debug': {}, + }, + # Added by Ariadne + 'exception': { + 'context': Whatever, + 'stacktrace': Whatever, + }, + }, + } + + # === Test: internal server error + query = 'query { unexpected_error }' + res = api_client.graphql_sync(query) + assert res.graphql_error == { + 'message': 'Fail', + 'path': ['unexpected_error'], + 'locations': Whatever, + 'extensions': { + # Application error info + 'error': { + # F_UNEXPECTED_ERROR + 'name': 'F_UNEXPECTED_ERROR', + 'httpcode': 500, + 'title': 'Generic server error', + 'error': 'Fail', + 'fixit': 'Please try again in a couple of minutes. If the error does not go away, contact support and describe the issue', + 'info': {}, + 'debug': { + # References errors with traceback + 'errors': [ + { + 'msg': 'Fail', + 'type': 'RuntimeError', + 'trace': [ + 'middleware/unexpected_errors.py:unexpected_errors_middleware_impl', + 'middleware/documented_errors.py:middleware', + 'middleware/documented_errors.py:documented_errors_middleware_impl', + 'graphql/query.py:resolve_unexpected_error', + ], + } + ] + }, + }, + # Added by Ariadne + 'exception': { + 'context': Whatever, + 'stacktrace': Whatever, + }, + } + } + + # === Test: validation error: argument, input type field + # language=graphql + query = 'query ($first: Int) { list_users(first: $first) }' + res = api_client.graphql_sync(query, first='INVALID') + assert res.errors == [ + # Argument error: `first` got an invalid value + { + # 'message': "Int cannot represent non-integer value: 'INVALID'", # the original + 'message': "Not a valid number", # Improved, human-readable + 'locations': Whatever, + 'extensions': { + # Added validation info + 'validation': { + 'variable': 'first', + 'path': ['first',], + 'message': "Not a valid number", + }, + # Added by Ariadne + 'exception': None, + } + }, + ] + \ No newline at end of file diff --git a/misc/readme-app/tests/test_hello.py b/misc/readme-app/tests/test_hello.py new file mode 100644 index 0000000..c0e2484 --- /dev/null +++ b/misc/readme-app/tests/test_hello.py @@ -0,0 +1,25 @@ +from .conftest import GraphQLClient, ApiClient + +def test_hello(graphql_client: GraphQLClient): + """ Test hello() on GraphQL schema """ + #language=GraphQL + q_hello = """ + query { + hello + } + """ + + res = graphql_client.execute(q_hello) + assert res['hello'] == 'Welcome' + +def test_hello_api(api_client: ApiClient): + """ Test hello() on FastAPI GraphQL endpoint """ + #language=GraphQL + q_hello = """ + query { + hello + } + """ + + res = api_client.graphql_sync(q_hello) + assert res['hello'] == 'Welcome' diff --git a/misc/readme-app/tests/test_model_match.py b/misc/readme-app/tests/test_model_match.py new file mode 100644 index 0000000..c1fe480 --- /dev/null +++ b/misc/readme-app/tests/test_model_match.py @@ -0,0 +1,38 @@ +from apiens.testing import model_match + +def test_model_match(): + def main(): + # Convert every model to its intermediate shape + db_user = model_match.match(User) + pd_user = model_match.match(UserSchema) + + # Compare DB `User` model to Pydantic `UserSchema` + assert pd_user == model_match.select_fields( + # Exclude a few fields from comparison + db_user, + model_match.exclude('password'), + ) + + # Models + import sqlalchemy as sa + import sqlalchemy.orm + import sqlalchemy.ext.declarative + + Base = sa.ext.declarative.declarative_base() + + class User(Base): + __tablename__ = 'users' + + id = sa.Column(sa.Integer, primary_key=True) + login = sa.Column(sa.String) + password = sa.Column(sa.String) + + import pydantic as pd + from typing import Optional + + class UserSchema(pd.BaseModel): + id: int + login: Optional[str] + + # Go + main() \ No newline at end of file diff --git a/misc/readme-app/tests/test_object_match.py b/misc/readme-app/tests/test_object_match.py new file mode 100644 index 0000000..c2dc6ea --- /dev/null +++ b/misc/readme-app/tests/test_object_match.py @@ -0,0 +1,92 @@ +import random +from apiens.testing.object_match import ( + Whatever, check, Parameter, + DictMatch, ObjectMatch, +) + + +def test_parameter(): + def main(): + # Inspect key by key + res = execute_api_request() + assert res['user']['id'] > 0 # inspect the dynamic field + assert res['user']['login'] == 'kolypto' + assert res['user']['name'] == 'Mark' + + # Pop + res = execute_api_request() + user_id = res['user'].pop('id') # pop the dynamic field + assert res == { + 'user': { + 'login': 'kolypto', + 'name': 'Mark', + } + } + assert user_id > 0 + + # Whatever + res = execute_api_request() + assert res == { + 'user': { + # Ignore the value: equality always give True + 'id': Whatever, + 'login': 'kolypto', + 'name': 'Mark', + } + } + + # Check + res = execute_api_request() + assert res == { + 'user': { + # Use a lambda function to test the value + 'id': check(lambda v: v>0), + 'login': 'kolypto', + 'name': 'Mark', + } + } + + # Parameter + res = execute_api_request() + assert res == { + 'user': { + # Capture the value into a variable + 'id': (user_id := Parameter()), + 'login': 'kolypto', + 'name': 'Mark', + } + } + # Use the value + assert user_id.value > 0 + print(user_id.value) + + # DictMatch + res = execute_api_request() + assert res == { + # Partial dict match: only named keys are compared + 'user': DictMatch({ + 'login': 'kolypto', + 'name': 'Mark', + }) + } + + # ObjectMatch + from collections import namedtuple + Point = namedtuple('Point', ('x', 'y')) + + point = Point(0, 100) + assert point == ObjectMatch(x=0) + + + def execute_api_request(): + return { + 'user': { + 'id': random.randint(0, 1000), + 'login': 'kolypto', + 'name': 'Mark', + } + } + + main() + + diff --git a/pyproject.toml b/pyproject.toml index 7c9ff35..566c599 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ ariadne = {version = ">= 0.13.0", optional = true} pytz = {version = ">= 2020.1", optional = true} python-jose = {version = ">= 1.0.0", optional = true} python-dotenv = {version = ">= 0.15.0", optional = true} +pint = {version = ">= 0.10.0", optional = true} [tool.poetry.dev-dependencies] nox = ">=2020.8.22" @@ -36,6 +37,8 @@ pytz = ">= 2022.1" pytest-asyncio = ">= 0.18.3" python-jose = ">= 3.2.0" python-dotenv = ">= 0.20.0" +Pint = "^0.19.2" +typer = "^0.6.1" [build-system]