{"id":15192568,"url":"https://github.com/kolypto/py-apiens","last_synced_at":"2025-10-02T08:30:49.684Z","repository":{"id":53343348,"uuid":"298746447","full_name":"kolypto/py-apiens","owner":"kolypto","description":"Modern CRUD APIs, with queries! ","archived":true,"fork":false,"pushed_at":"2022-09-08T10:15:52.000Z","size":560,"stargazers_count":2,"open_issues_count":0,"forks_count":1,"subscribers_count":2,"default_branch":"master","last_synced_at":"2025-01-16T02:52:48.184Z","etag":null,"topics":["api","fastapi","graphql","jessiql","sqlalchemy"],"latest_commit_sha":null,"homepage":"","language":"Python","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/kolypto.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2020-09-26T05:45:39.000Z","updated_at":"2024-06-24T09:26:21.000Z","dependencies_parsed_at":"2022-09-04T19:24:46.291Z","dependency_job_id":null,"html_url":"https://github.com/kolypto/py-apiens","commit_stats":null,"previous_names":[],"tags_count":3,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kolypto%2Fpy-apiens","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kolypto%2Fpy-apiens/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kolypto%2Fpy-apiens/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/kolypto%2Fpy-apiens/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/kolypto","download_url":"https://codeload.github.com/kolypto/py-apiens/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":234957767,"owners_count":18913346,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api","fastapi","graphql","jessiql","sqlalchemy"],"created_at":"2024-09-27T21:42:41.881Z","updated_at":"2025-10-02T08:30:44.304Z","avatar_url":"https://github.com/kolypto.png","language":"Python","funding_links":[],"categories":[],"sub_categories":[],"readme":"[![Test](https://github.com/kolypto/py-apiens/workflows/Test/badge.svg)](/kolypto/py-apiens/actions)\n[![Pythons](https://img.shields.io/badge/python-3.9%E2%80%933.10-blue.svg)](noxfile.py)\n\nApiens\n======\n\nApiens (API sapiens) is a collection of tools for developing API applications with FastAPI and/or GraphQL.\n\nIt includes some solutions and best practices for:\n\n* Application configuration with env variables\n* Error reporting with error codenames and structured error info\n* Failsafe tools to help write better async code\n* SqlAlchemy tools for loading, saving, and inspecting objects\n\nBecause this library is not a framework but a collection of tools, our docs start with a guide.\nWe'll create a new application :)\n\n\n\n\n\n\nGuide\n=====\nWe will build a new GraphQL application that sits on top of FastAPI application.\n\nDemo application sources are available under [misc/readme-app](misc/readme-app/).\n\n\n\n\n\n\n## Application Configuration: Environment Variables\nOur application will support 3 running modes:\n\n1. `dev`: Development mode. Your code can enable some debugging features when this mode is on.\n2. `prod`: Production mode. All debugging features are disabled.\n3. `test:` Testing mode. Used when running unit-tests.\n\nThe current environment is determined by the `ENV` environment variable. Like this:\n\n```console\n$ ENV=prod app run \n```\n\n\n\n\n\n\n### Configuration file\nOur configuration will live in a module as a variable that's easy to import like this:\n\n```python\nfrom app.globals.config import settings\n```\n\nHere's the module:\n\n```python\n# config.py\n\nimport pydantic as pd\n\nclass Settings(pd.BaseSettings):\n    \"\"\" Application settings \"\"\"\n    # Human-readable name of this project\n    # Used in titles, emails \u0026 stuff\n    PROJECT_NAME: str = 'My Application'\n\n    class Config:\n        # Use this prefix for environment variable names.\n        env_prefix = 'APP_'\n        case_sensitive = True\n\n\n# Load default environment values from .env files.\nfrom apiens.tools.settings import (\n    set_default_environment, \n    load_environment_defaults_for, \n    switch_environment_when_running_tests,\n)\nset_default_environment('APP_ENV', default_environment='dev')  \nload_environment_defaults_for('APP_ENV')\nswitch_environment_when_running_tests('APP_ENV')\n\n\n# Init settings\nsettings = Settings()\n```\n\n\n\n\n\n\n### DotEnv files\nThe application will read its configuration from environment variables, but will also load the following dotenv files:\n\n* `/misc/env/{name}.env`: Configuration variables' values\n* `/misc/env.local/{name}.env`: Configuration overrides for running in the so-called \"local mode\" (see below)\n* `/.{name}.env`: Ad-hoc variable value overrides\n\nSo, if your current environment is\n\n```console\n$ export ENV=dev \u0026\u0026 cd ~/code/myapp \n```\n\nThen it will:\n\n* Load `/misc/env/dev.env`\n* Load `/misc/env.local/dev.env` (only when running locally)\n* Load `/.dev.env` (only when running locally)\n* Read variable values from the environment and override any previous values\n\nThis logic is implemented in the [`load_environment_defaults_for()`](apiens/tools/settings/env.py).\n\n\n\n\n\n\n\n### Running Locally\nWhen your application runs in Docker, it needs to use `postgres` as the host name. \nHowever, when you run the same application locally, on your host, it needs to use `localhost` as the host name.\n\nTo support the \"local\" running mode, we have this `/misc/env.local` folder. \nTo activate this mode, use the following `.envrc` [direnv](https://direnv.net/) file:\n\n```bash\n#!/bin/bash\nexport ENV_RUNNING_LOCALLY=1\n```\n\n\n\n\n\n\n### direnv file\nOkay, while we're at it, here's a `.envrc` file that will load all `.env` files into the current environment:\n\n```bash\n#!/bin/bash\n\n# auto-set the environment\n[ -z \"${APP_ENV}\" ] \u0026\u0026 export APP_ENV='dev'\n\n# auto-load .env into the environment\n[ -f \"misc/envs/${APP_ENV}.env\" ] \u0026\u0026 dotenv \"misc/envs/${APP_ENV}.env\"\n[ -f \"misc/envs.local/${APP_ENV}.env\" ] \u0026\u0026 dotenv \"misc/envs.local/${APP_ENV}.env\"\n[ -f \".${APP_ENV}.env\" ] \u0026\u0026 dotenv \".${APP_ENV}.env\"\n\n# Indicate that we are running locally (not in Docker)\nexport ENV_RUNNING_LOCALLY=1\n\n# Custom PS1\n# Your shell must have an \"echo -n $CUSTOM_PS1\" in order for this to work\nexport CUSTOM_PS1=\"$PREVENT_ENV: \"\n\n# Automatically activate poetry virtualenv\nif [[ -f \"pyproject.toml\" ]]; then\n    # create venv if it doesn't exist; then print the path to this virtualenv\n    export VIRTUAL_ENV=$(poetry run true \u0026\u0026 poetry env info --path)\n    export POETRY_ACTIVE=1\n    PATH_add \"$VIRTUAL_ENV/bin\"\n    echo \"Activated Poetry virtualenv: $(basename \"$VIRTUAL_ENV\")\"\nfi\n```\n\n\n\n\n\n\n### Configuration Mixins\nThe [tools.settings.mixins](apiens/tools/settings/mixins.py) provides some mixin classes for your `Settings` class.\n\nThe `EnvMixin` allows you to check the current environment, like this:\n\n```python\nif not settings.is_production:\n    ...  # add more debugging information\n```\n\nThe `LocaleMixin` is a convenience for storing the current locale (language) and timezone:\n\n```python\nsettings.LOCALE  # -\u003e 'en'\nsettings.TZ  # -\u003e 'Europe/Moscow'\n```\n\nThe `DomainMixin` and `CorsMixin` help you configure your web application for allowed Hosts and CORS checks:\n\n```python\nsettings.SERVER_URL  # -\u003e 'https://example.com/'\nsettings.CORS_ORIGINS  # -\u003e ['https://example.com', 'https://example.com:443']\n```\n\nThe `SecretMixin` is for keeping your application's secret key for encryption and stuff:\n\n```python\nsettings.SECRET_KEY  # -\u003e '...'\n```\n\nThe `PostgresMixin` reads Postgres configuration from the environment variables you also use for the Docker container:\n\n```python\n# Read from the environment:\nsettings.POSTGRES_HOST  # -\u003e 'localhost'\n# Generated:\nsettings.POSTGRES_URL  # -\u003e 'postgres://user:pass@localhost:port/'\n```\n\nThe `RedisMixin` reads Redis configuration:\n\n```python\nsettings.REDIS_URL  # -\u003e 'redis://@localhost/0\n```\n\nNow, in our Demo application, we'll use some mixins to get a Postgres connection and settings for the web application:\n\n```python\n# config.py\nclass Settings(EnvMixin, DomainMixin, CorsMixin, PostgresMixin, pd.BaseSettings):\n    \"\"\" Application settings \"\"\"\n\n    # Human-readable name of this project\n    # Used in titles, emails \u0026 stuff\n    PROJECT_NAME: str = 'My Application'\n\n    class Config:\n        # Use this prefix for environment variable names.\n        env_prefix = 'APP_'\n        case_sensitive = True\n```\n\n\n\n\n\n\n### DotEnv files\nNow, when we have the `Settings` class that reads our configuration into `config.settings`, \nwe need to give it the actual configuration values.\n\nThese values can come from the environment:\n\n```python\n$ ENV=dev POSTGRES_HOST=localhost POSTGRES_PORT=54321 APP_SERVER_URL=http://localhost/ app run\n```\n\nbut surely this is too verbose.\n\nInstead, we'll create DotEnv files under `misc/env`:\n\n```env\n# misc/env/dev.env\nENV=dev\n\nPOSTGRES_HOST=localhost\nPOSTGRES_PORT=5432\nPOSTGRES_USER=postgres\nPOSTGRES_PASSWORD=postgres\nPOSTGRES_DB=app\n\nAPP_SERVER_URL=http://localhost\nAPP_CORS_ORIGINS=http://localhost\n```\n\nAnd another one, for unit-tests:\n\n```env\nENV=test\n\nPOSTGRES_HOST=localhost\nPOSTGRES_PORT=54321\nPOSTGRES_USER=postgres\nPOSTGRES_PASSWORD=postgres\nPOSTGRES_DB=app_test\n\nAPP_SERVER_URL=http://localhost\nAPP_CORS_ORIGINS=http://localhost\n```\n\nNote that a different database should be used for unit-tests: otherwise every test run would overwrite your local database =\\\n\n\n\n\n\n\n## Database init\nLet's configure a Postgres database using our configuration:\n\n```python\n# postgres.py\nfrom collections import abc\nimport sqlalchemy as sa\n\nfrom apiens.tools.settings import unit\n\n# Import settings\nfrom app.globals.config import settings\n\n\n# Prepare some units for readability\nmin = unit('minute')\nsec = unit('second')\n\n# Initialize SqlAlchemy Engine: the connection pool\nengine: sa.engine.Engine = sa.create_engine(\n    # Use settings\n    settings.POSTGRES_URL,\n    future=True,\n    # Configure the pool of connections\n    pool_size=20,\n    max_overflow=10,\n    pool_pre_ping=True,\n    # Use `unit` to make the value more readable\n    pool_recycle=10 * min \u003e\u003e sec,\n)\n\n# Initialize the SessionMaker: the way to get a SqlAlchemy Session\nfrom sqlalchemy.orm import Session\nSessionMakerFn = abc.Callable[[], Session]\n\nSessionMaker: SessionMakerFn = sa.orm.sessionmaker(\n    autocommit=False,\n    autoflush=False,\n    bind=engine,\n)\n```\n\nThanks to `settings` being a module variable, we can just import it, and initialize our database as module variables.\nNow, your code can use the database like this:\n\n```python\nfrom app.globals.postgres import SessionMaker\n\nwith SessionMaker() as ssn:\n    ...  # do your stuff\n```\n\n\n\n\n\n\n### DB Connection Pool\nIt's really important to use `SessionMaker` as a context manager: because this way you make sure that every connection\nyou've checked out will then be returned to the pool for reuse.\n\nHowever, if you used your sessions like this:\n\n```python\nssn = SessionMaker()\n...\nssn.close()\n```\n\nThere is a chance that an exception will be raised before you do `ssn.close()` and the connection will remain checked out.\nSuch a connection will waste the limited resources of the pool and your application will malfunction.\n\nBecause it's so important to make sure that every checked out connection is returned to the pool, \nwe add a piece of code that users [`TrackingSessionMaker`](apiens/tools/sqlalchemy/session/session_tracking.py) \nthat checks whether all your SqlAlchemy connections have been properly closed:\n\n```python\n# In testing, make sure that every connection is properly closed.\nif settings.is_testing:\n    # Prepare a new SessionMaker that tracks every opened connection\n    from apiens.tools.sqlalchemy.session.session_tracking import TrackingSessionMaker\n    SessionMaker = TrackingSessionMaker(class_=SessionMaker.class_, **SessionMaker.kw)\n\n    # Define a function for unit-tests: check that all Sessions were properly close()d\n    def assert_no_active_sqlalchemy_sessions():\n        SessionMaker.assert_no_active_sessions()  \n# postgres.py\n...\n# In testing, make sure that every connection is properly closed.\nif settings.is_testing:\n    # Prepare a new SessionMaker that tracks every opened connection\n    from apiens.tools.sqlalchemy.session.session_tracking import TrackingSessionMaker\n    SessionMaker = TrackingSessionMaker(class_=SessionMaker.class_, **SessionMaker.kw)\n\n    # Define a function for unit-tests: check that all Sessions were properly close()d\n    def assert_no_active_sqlalchemy_sessions():\n        SessionMaker.assert_no_active_sessions()  \n```\n\nThis code defines a function, `assert_no_active_sqlalchemy_sessions()`, that should be run after each unit-test.\nIf your code hasn't `close()`d a Session, this function will find a dangling Session and complain.\n\nThis is your safeguard against connections left open unintentionally.\n\n\n\n\n\n\n## Structured Errors\nThis is how default FastAPI errors look like:\n\n```javascript\n{\n    detail: \"Not Found\"\n}\n```\n\nand this is how default GraphQL errors look like:\n\n```javascript\n{\n  //...\n  \"errors\": [\n    {\n      \"message\": \"Fail\",\n      \"path\": [\"unexpected_error\"],\n      \"extensions\": {\n        \"exception\": {\n          \"stacktrace\": [\"Traceback\", \"File ..., line ... in execute_field\"],\n          \"context\": {/*...*/}\n        }\n      }\n    }\n  ]\n}\n```\n\nThis is ok for developers, but not for the UI: in order to learn what has happened, the UI would have to parse error message.\n\nApiens offers *structured errors*: errors where every error has a codename.\n\n\n\n\n\n\n### Application Errors\nWith Apiens, you'll have two types of exceptions:\n\n* Ordinary Python exceptions: seen as *unexpected errors*\n* Application Errors: errors that are meant to be reported to the API user\n\nApplication errors inherit from [BaseApplicationError](apiens/error/base.py) and have the following fields:\n\n* `error`: The negative message: what has gone wrong.\n* `fixit`: The positive message: what the user can do to fix it (user-friendly)\n\n    Thus, every error will have two messages: one for developers, and the other one -- for users.\n\n* `name`: Error codename: the name of the class. E.g. \"E_NOT_FOUND\". \n\n    This is the machine-readable codename for the error that the UI can use to react to it.\n\n* `title`: Generic name for the error class that does not depend on the context. E.g. \"not found\".\n* `httpcode`: The HTTP code to use for this error (when in HTTP context)\n* `info`: Additional structured information about the error\n* `debug`: Additional structured information, only included when the app is not in production\n\nApplication errors can be raised like this:\n\n```python\nfrom apiens.error import exc\n\nraise exc.E_NOT_FOUND(\n    # Error: the negative message\n    \"User not found by email\",\n    # Fixit: the positive message\n    \"Please check the provided email\",\n    # additional information will be included as `info`\n    object='User',\n    email='user@example.com',\n)\n```\n\nSuch an error will be reported as structured JSON Error Object by FastAPI:\n\n```javascript\n{\n    // Error response starts with the \"error\" key\n    error: {\n        // Error codename\n        name: \"E_NOT_FOUND\",\n        // Two error messages: negative `error`, and positive `fixit`\n        error: \"User not found by email\",\n        fixit: \"Please check the provided email\",\n        // Static information\n        httpcode: 404,\n        title: \"Not found\",\n        // Structured information about this error\n        info: {\n            // UI can use this information for display\n            object: \"User\",\n            email: \"user@example.com\"\n        },\n        debug: { }\n    }\n}\n```\n\nTo integrate with this Application Errors framework, we first need to create a file for our application's exceptions.\nFor starters, we'll reuse some of the standard exceptions pre-defined in the [apiens.error.exc](apiens/error/exc.py) module:\n\n```python\n# exc.py\nimport apiens.error.exc\n\n# Base class for Application Errors\nfrom apiens.error import BaseApplicationError\n\n# Specific application errors\n# We'll reuse some errors provided by apiens\nfrom apiens.error.exc import (\n    E_API_ARGUMENT,\n    E_API_ACTION,\n    E_NOT_FOUND,\n    F_FAIL,\n    F_UNEXPECTED_ERRORS,\n)\n```\n\nAnd then we'll install an exception handler (FastAPI) and an error formatter (GraphQL) to render such errors properly.\n\nBecause these errors are meant to be returned by the API, they have API schemas:\n\n* \u003capiens/error/error_object/python.py\u003e: TypedDict definitions for the Error Object\n* \u003capiens/error/error_object/pydantic.py\u003e: Pydantic definitions for the Error Object (to be used with FastAPI)\n* \u003capiens/error/error_object/schema.graphql\u003e: GraphQL definitions for the Error Object\n\n\n\n\n\n\n### Converting Errors\nBy convention, every uncaught Python exception is seen as *unexpected error* and is reported as \"F_UNEXPECTED_ERROR\".\nThis is achieved using the so-called \"converting\" decorator/context manager:\n[converting_unexpected_errors()](apiens/error/converting/exception.py)\n\n```python\nfrom apiens.error.converting.exception import converting_unexpected_errors\n\n# Convert every Python exception to `F_UNEXPECTED_ERROR`\nwith converting_unexpected_errors():\n    raise RuntimeError('Fail')\n```\n\nIn some cases you may want to customize how a Python error gets converted into an Application error:\nfor instance, you may have an API client with custom errors that you want to report as `F_NETWORK_SERVICE` or something.\n\nTo customize how errors are converted into application errors, define a `default_api_error()` method on them.\nSee [ConvertsToBaseApiExceptionInterface](apiens/error/converting/base.py) protocol.\n\nNote that there are other *converting* decorators as well:\n\n* [`converting_sa_errors()`](apiens/error/converting/sqlalchemy.py) converts SqlAlchemy errors: \n  for instance, `sa.orm.exc.NoResultFound` gets converted into `E_NOT_FOUND`, and `UniqueViolation` into `E_CONFLICT_DUPLICATE`.\n* [`converting_jessiql_errors()`](apiens/error/converting/jessiql.py) converts JessiQL errors into `E_API_ARGUMENT`.\n* [`converting_apiens_errors()`](apiens/error/converting/apiens.py)\n\n\n\n\n\n\n## FastAPI application\nLet's start by putting together a FastAPI application.\n\n```python\n# fastapi/app.py\nfrom fastapi import FastAPI\n\nfrom app.globals.config import settings\n\n\n# ASGI app\nasgi_app = FastAPI(\n    title=settings.PROJECT_NAME,\n    description=\"\"\" Test app \"\"\",\n    # `settings` controls debug mode of FastAPI\n    debug=not settings.is_production,\n)\n\n# Attach GraphQL routes\nfrom app.expose.graphql.app import graphql_app\nasgi_app.mount('/graphql/', graphql_app)\n```\n\nThis part of the application is pretty straightforward: we initialized an ASGI application,\nand mounted a GraphQL ASGI application onto `/graphql/` route.\n\nLet's see how such an API reports a `RuntimeError`:\n\n```javascript\nInternal Server Error\n```\n\nBy adding using [`register_application_exception_handlers()`](apiens/tools/fastapi/exception_handlers.py),\nwe can improve the way errors are reported: they will become structured Application Errors:\n\n```python\n# fastapi/app.py\nfrom apiens.tools.fastapi.exception_handlers import register_application_exception_handlers\nregister_application_exception_handlers(asgi_app, passthru=settings.is_testing)\n```\n\nThey will:\n\n* Report every error as `F_UNEXPECTED_ERROR`\n* Report FastAPI validation errors as `E_CLIENT_VALIDATION` \n* Report every application error as proper JSON object\n* For invalid urls (\"route not found\"), will generate a list of suggested API paths \n\n\n\n\n\n\n## GraphQL application\n\nLet's start a GraphQL application. First, we create a GraphQL schema, and then create an ASGI application\nthat serves the schema. \n\n\n\n\n\n\n### GraphQL Schema\n\n```python\n# graphql/schema.py\n\nimport os\nimport ariadne\n\nimport apiens.error.error_object\nfrom apiens.tools.ariadne.schema.load import load_schema_from_module\n\napp_schema = ariadne.make_executable_schema([\n        # Load all *.graphql files from this folder\n        ariadne.load_schema_from_path(os.path.dirname(__file__)),\n        # Load a *.graphql file from a module\n        load_schema_from_module(apiens.error.error_object, 'schema.graphql'),\n    ],\n    graphql_definitions,\n    ariadne.snake_case_fallback_resolvers,\n)\n```\n\nLet's add a few more line to improve how `Int` and `Float` errors are reported:\nhuman-readable error messages are implemented by \n[`human_readable.install_types_to_schema()`](apiens/tools/graphql/errors/human_readable.py)\nthat overrides some built-in resolvers with user-friendly error messages that can now be used in the UI:\n\n```python\n# graphql/schema.py\n\n# Improve error messages from scalars like Int and Float\nfrom apiens.tools.graphql.errors import human_readable\nhuman_readable.install_types_to_schema(app_schema)\n```\n\nThis module, `human_readable`, improves the \n\nBefore: \n\n\u003e 'message': \"Int cannot represent non-integer value: 'INVALID'\",\n\nAfter:\n\n\u003e 'message': \"Not a valid number\",  # Improved, human-readable\n\n\n\n\n\n\n### GraphQL ASGI Application\n\nNow we need to create an ASGI application.\n\nThis is necessary because the GraphQL schema knows nothing about HTTP requests, JSON payload, etc.\nThis ASGI application takes JSON data from the request body and passes it to the schema for execution.\nThe schema gives the response, which is converted into HTTP JSON response and sent to the client.\n\nSo, let's init this application with Ariadne:\n\n```python\nfrom ariadne.asgi import GraphQL\nfrom apiens.tools.graphql.middleware.documented_errors import documented_errors_middleware\nfrom apiens.tools.graphql.middleware.unexpected_errors import unexpected_errors_middleware\nfrom apiens.tools.ariadne.errors.format_error import application_error_formatter\n\nfrom app.globals.config import settings\nfrom app import exc\nfrom .schema import app_schema\n\n\n# Init the ASGI application\ngraphql_app = GraphQL(\n    # The schema to execute operations against\n    schema=app_schema,\n    # The context value. None yet.\n    context_value=None,\n    # Error formatter presents Application Errors as proper JSON Error Objects\n    error_formatter=application_error_formatter,\n    # Developer features are only available when not in production\n    introspection=not settings.is_production,\n    debug=not settings.is_production,\n    # Some middleware\n    middleware=[\n        # This middleware makes sure that every application error is documented.\n        # That is, if `E_NOT_FOUND` can be returned by your `getUserById`, \n        # then its docstring should contain something like this:\n        # \u003e Errors: E_NOT_FOUND: the user is not found\n        documented_errors_middleware(exc=exc),\n\n        # Converts every Python exception into F_UNEXPECTED_ERROR.\n        # Users `converting_unexpected_errors()`\n        unexpected_errors_middleware(exc=exc),\n    ],\n)\n```\n\nThe main part is where the ASGI application gets the `schema`.\n\nLet's have a closer look at the `middleware` and the `error_formatter` keys.\n\n\n\n\n\n#### GraphQL Middleware\n\nThis initializer mentions two middlewares:\n\n* [`documented_errors_middleware`](apiens/tools/graphql/middleware/documented_errors.py) \n  requires that every Application Error is documented. \n  That is, if your field raises `E_NOT_FOUND`, your field's docstring must contain something like\n\n  \u003e Error: E_NOT_FOUND: the user is not found\n\n  The middleware simply checks whether the docstring of the field, or the parent object, \n  mentions the error by name. This simple check makes sure that the UI won't get any surprises\n  from your GraphQL API: it will know exactly which errors it should expect.\n\n  If an error is undocumented, an additional error is reported.\n\n* [`unexpected_errors_middleware`](apiens/tools/graphql/middleware/unexpected_errors.py) \n  converts every Python exception into `F_UNEXPECTED_ERROR`.\n  This basically makes sure that unexpected errors are also reported as application errors.\n\n\n\n\n\n\n#### GraphQL Error Formatter\nThis ASGI application initializer also has a custom error formatter.\n\nThe error formatter, [`application_error_formatter`](apiens/tools/ariadne/errors/format_error.py), is a function that gets a `GraphQLError`\nand adds a key to `'extensions'` with additional information about this error.\nNamely, if it's an Application Error, it will contain its `name`, `error`, `fixit`, and other fields.\n\nBefore:\n\n```javascript\n{\n  \"errors\": [\n    {\n      \"message\": \"Fail\",\n      \"path\": [\"unexpected_error\"],\n      \"extensions\": {\n        \"exception\": {\n          // Python traceback\n          \"stacktrace\": [\"Traceback:\", \"...\"],\n        }\n      }\n    }\n  ]\n}\n```\n\nAfter:\n\n```javascript\n{\n  \"errors\": [\n    {\n      \"message\": \"Fail\",\n      \"path\": [\"unexpected_error\"],\n      \"extensions\": {\n        // The Error Object: additional, structured, information about the error\n        \"error\": {\n          // Error codename\n          \"name\": \"F_UNEXPECTED_ERROR\",\n          // Static information: http code, error message\n          \"httpcode\": 500,\n          \"title\": \"Generic server error\",\n          // Two error messages: negative, and positive\n          \"error\": \"Fail\",\n          \"fixit\": \"Please try again in a couple of minutes. If the error does not go away, contact support and describe the issue\",\n          // Additional structured information, if any \n          \"info\": {},\n          // Additional debug information (only included in non-production mode)\n          \"debug\": {\n            \"errors\": [\n              {\n                // The original Python exception\n                \"type\": \"RuntimeError\",\n                \"msg\": \"Fail\",\n                // Easy-to-read traceback\n                \"trace\": [\n                  \"middleware/unexpected_errors.py:unexpected_errors_middleware_impl\",\n                  \"middleware/documented_errors.py:middleware\",\n                  \"graphql/query.py:resolve_unexpected_error\"\n                ]\n              }\n            ]\n          }\n        },\n        \"exception\": {\n          \"stacktrace\": [\"Traceback:\", \"...\"],\n        }\n      }\n    }\n  ]\n}\n```\n\nIt also fixes some validation errors to be more readable: \noriginal validation errors look like this:\n\n\u003e Variable '$user' got invalid value -1 at 'user.age'; Expected type 'PositiveInt'. Must be positive\n\nSuch error messages are converted into:\n\n```javascript\n{\n  // The message is now human-readable\n  'message': \"Must be positive\",\n  'extensions': {\n    // Validation error is returned as structured data\n    'validation': {\n      'variable': '$user',\n      'path': ['user', 'age'],\n    }\n  }\n}\n```\n\n\n\n\n\n\n### Resolver Markers\n\nLet's implement a resolver.\nA resolver is a Python function that gets called to provide a value for the field:\n\n```python\nimport ariadne\n\nQuery = ariadne.QueryType()\n\n@Query.field('hello')\ndef resolve_hello(_, info: ResolveInfo):\n    return 'Welcome'\n```\n\nNot here's the catch: your resolver will get executed inside the async loop.\nIt's fine when you use `async` functions or functions that do not block.\n\nHowever, if your sync resolver function is blocking -- i.e. is I/O bound or CPU bound -- \nthen you cannot just use it like this: it would block the whole asyncio loop!\n\nYou need to run it in a threadpool, like this:\n\n```python\nfrom apiens.tools.python.threadpool import runs_in_threadpool\n\n@runs_in_threadpool\ndef resolve_something(_, info):\n    with SessionMaker() as ssn:\n        something = ssn.query(...).one()  # blocking!!!      \n```\n\nThis thing is, probably, **the** most important to know about async applications.\nBecause it is so important to remember this decorator, the Apiens library provides a way to make sure\nthat developers do not forget about this issue.\n\nIt provides three decorators:\n\n* [`@resolves_in_threadpool`](apiens/tools/graphql/resolver/resolver_marker.py)\n  to decorate a blocking sync function that will be run in a threadpool.\n* [`@resolves_nonblocking`](apiens/tools/graphql/resolver/resolver_marker.py)\n  to decorate a sync function that promises not to block (i.e. do no networking and such)\n* [`@resolves_async`](apiens/tools/graphql/resolver/resolver_marker.py)\n  -- an optional decorator for async functions. Just for completeness.\n\nAnd a unit-test tool to make sure that your schema has every resolver decorated:\n\n```python\n# tests/test_api.py\n\n\nfrom app.expose.graphql.schema import app_schema\nfrom apiens.tools.graphql.resolver.resolver_marker import assert_no_unmarked_resolvers\n\n\ndef test_no_unmarked_resolvers():\n    \"\"\" Make sure that every resolver is properly decorated. \"\"\"\n    assert_no_unmarked_resolvers(app_schema)\n\n```\n\nIf any resolver is not decorated, the unit-test would fail like this:\n\n\u003e AssertionError: Some of your resolvers are not properly marked. Please decorate with either @resolves_in_threadpool or @resolves_nonblocking. \n\u003e\n\u003e List of undecorated resolvers:      \n\u003e \\* resolve_hello (module: app.expose.graphql.query)\n \n\n\n\n\n## Testing\n\nWe'll start with configuring an API client to make requests to our API.\n\nNow, there are two ways to make there requests:\n\n1. We can execute operations against the GraphQL `schema` through `schema.execute()`.\n\n  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).\n\n  Such a test would verify that your business-logic works, but won't make sure that your application \n  correctly integrates with the HTTP layer.\n\n2. We can execute requests through the FastAPI stack and GraphQL ASGI application. \n  \n  This test involves the whole application stack, but it's slower to set up, and may lose some valuable information\n  about exceptions because they are converted into JSON.\n\nThe recommendation is to unit-test the GraphQL application because it's faster and more flexible,\nbut also pick some key APIs and unit-test their features involving the whole stack. This is especially true about\nany APIs that are supposed to use cookies, authentication, user state, and HTTP headers.\n\nLet's start by defining a GraphQL client and an API client in `conftest.py`.\n\n\n\n\n\n\n### GraphQL client\n\nApiens provides a convenient [GraphQLTestClient](apiens/tools/graphql/testing/test_client.py) client \nfor testing operations against your graphql schema:\n\n```python\n# tests/conftest.py\n\nfrom app.expose.graphql.schema import app_schema\nfrom apiens.tools.graphql.testing import test_client\n\n\nclass GraphQLClient(test_client.GraphQLTestClient):\n    def __init__(self):\n        super().__init__(schema=app_schema, debug=True)\n\n    # The GraphQL unit test needs to initialize its own context for the request.\n    @contextmanager\n    def init_context_sync(self):\n        yield {}\n\n@pytest.fixture()\ndef graphql_client() -\u003e GraphQLClient:\n    \"\"\" Test client for GraphQL schema \"\"\"\n    with GraphQLClient() as c:\n        yield c\n```\n\nWhen you run GraphQL queries with this test client, you can benefit from the augmented \n[`GraphQLResult`](apiens/tools/graphql/testing/query.py) result object:\n\n```python\n# tests/test_hello.py\n\nfrom .conftest import GraphQLClient, ApiClient\n\ndef test_hello(graphql_client: GraphQLClient):\n    q_hello = \"\"\"\n        query {\n            hello\n        }\n    \"\"\"\n\n    # Execute operation, inspect response\n    res = graphql_client.execute(q_hello)\n    assert res.data['hello'] == 'Welcome'\n\n    # Execute operation, inspect response, expect it to be successful.\n    # This shortcut res['hello'] would raise any exceptions that may have happened.\n    res = graphql_client.execute(q_hello)\n    assert res['hello'] == 'Welcome'\n```\n\nIn addition to this `res[fieldName]` shortcut, [`GraphQLResult`](apiens/tools/graphql/testing/query.py) result object\noffers some more features:\n\n* `data`: the dict with results\n* `errors`: the list of reported errors (as JSON)\n* `exceptions`: the list of original Python exception objects (not JSON). Useful for re-raising.\n* `context`: the context used for the request. Useful for post-mortem inspection.\n* `ok`: was the request successful (i.e. did it go without errors?)\n* `raise_errors()`: raise any Python exceptions that may have happened.\n* `app_error_name`: get the name of the application error (e.g. `\"E_NOT_FOUND\"`)\n* Also see: `app_error`, `original_error`, `graphql_error` properties that help inspect the returned error:\n\n\n\n\n\n\n### FastAPI client\n\nNow we need another client that unit-tests APIs and involves the whole stack, i.e. the FastAPI application.\nIn fact, you can use the ordinary `fastapi.testing.TestClient`, but it will be quite inconvenient to use it \nfor testing a GraphQL application: you'd have to prepare request dict every time, and you'll get errors\nreported as JSON.\n\nApiens provides a [GraphQLClientMixin](apiens/tools/graphql/testing/test_client_api.py) mixin for your test class\nthat adds methods for executing GraphQL requests through FastAPI's test client's `post()`, \nand even uses a few tricks to make sure that you can get the original Python exception \n(rather than formatter exception JSON):\n\n```python\n# tests/conftest.py\nfrom app.expose.graphql.schema import app_schema\nfrom apiens.tools.graphql.testing import test_client_api\n\n\n# FastAPI test client, with a mixin that supports GraphQL requests\nclass ApiClient(test_client_api.GraphQLClientMixin, TestClient):\n    GRAPHQL_ENDPOINT = '/graphql/'\n\n@pytest.fixture()\ndef api_client() -\u003e ApiClient:\n    \"\"\" Test client for FastAPI, with GraphQL capabilities \"\"\"\n    with ApiClient(asgi_app) as c:\n        yield c\n```\n\nHere's how this client is used:\n\n```python\n# tests/test_hello.py\n\ndef test_hello_api(api_client: ApiClient):\n    \"\"\" Test hello() on FastAPI GraphQL endpoint \"\"\"\n    #language=GraphQL\n    q_hello = \"\"\"\n        query {\n            hello\n        }\n    \"\"\"\n\n    res = api_client.graphql_sync(q_hello)\n    assert res['hello'] == 'Welcome'\n```\n\n\n\n\n\n\n\n\n\n\n\nSpecial Tools\n=============\n\nWe've built a FastAPI-GraphQL application with the tools from Apiens. \nAs you see, it's not a framework, but a set of tools that address specific issues to make your API\na sweet solution to use :) \n\nThe rest of the document will describe specific tools that you may find useful here and there.\nLet's first see more unit-testing tools.\n\n\n\n\n\n\n## Unit-Testing Tools\n\n\n\n\n\n\n### Network Gag\nYour application likely uses some network services, and it's important to make sure \nthat in your unit-tests they all are properly mocked. That is, to make sure that your unit-tests\nmake no real network connections!\n\nThe [network_gag](apiens/testing/network_gag.py) provides a decorator (and a context manager)\nthat makes sure that your code does no network connections through urllib, aiohttp, or amazon client:\n\n```python\nwith network_gag():\n    ... # do your stuff without networking\n```\n\nIf your code attempts to communicate with the Internet, the gag would give you an exception: \n\n\u003e This unit-test has attempted to communicate with the Internet\n\u003e URL: http://service.mock/api/method\n\u003e Please use the `responses` library to mock HTTP in your tests. \n\u003e Cheers!\n\nWith pytest it's more convenient to use [network_gag_conftest](apiens/testing/network_gag_conftest.py).\nJust import this fixture into your `conftest.py`:\n\n```python\n# conftest.py\n\n# Network gag\nfrom apiens.testing.network_gag_conftest import stop_all_network, unstop_all_network\n```\n\nNow, if any unit-test attempts to communicate with the Internet, you'll get an exception.\nHowever, if some specific test needs to allow networking, use this mark:\n\n```python\nimport pytest\n\n@pytest.mark.makes_real_network_connections\ndef test_something_with_networking():\n  ...\n```\n\n\n\n\n\n### Object Match\n\nLets see a situation where simple `assert result == value` is not enough.\n\nSuppose your unit-test verifies the response of some API:\n\n```python\nres = execute_api_request()\nassert res == {\n    'user': {\n        'id': 19,\n        'login': 'kolypto',\n        'name': 'Mark',\n    }\n}\n```\n\nassertions work fine with static data like the `'login'` string there. \nBut this `'id'` is a dynamic value likely returned from some database and you cannot really\ncheck equality like that.\n\nWhen a dynamic value is inserted into a static structure with nested fields, \ndevelopers have to inspect the response key by key:\n\n```python\nres = execute_api_request()\nassert res['user']['id'] \u003e 0  # inspect the dynamic field\nassert res['user']['login'] == 'kolypto'\nassert res['user']['name'] == 'Mark'\n```\n\nor modify the response to keep it static:\n\n```python\nres = execute_api_request()\nuser_id = res['user'].pop('id')  # pop the dynamic field\nassert res == {\n  'user': {\n    'login': 'kolypto',\n    'name': 'Mark',\n  }\n}\nassert user_id \u003e 0\n```\n\nYou've done this before, have you? :) \n\nApiens offers several ways to unit-test dynamic values within complex structures.\n\nIn some cases, you don't really care which exact value is there. You just want to ignore it.\nUse the [`Whatever`](apiens/testing/object_match/okok.py) object: when compared to anything,\nit gives a `True`:\n\n```python\nres = execute_api_request()\nassert res == {\n    'user': {\n        # Ignore the value: equality always give True\n        'id': Whatever,\n        'login': 'kolypto',\n        'name': 'Mark',\n    }\n}\n```\n\nIf you do actually care which value is there and want to inspect it, \nuse [check()](apiens/testing/object_match/check.py) with a lambda function that will perform\nthe test when the two values are compared to one another:\n\n```python\nres = execute_api_request()\nassert res == {\n    'user': {\n        # Use a lambda function to test the value\n        'id': check(lambda v: v\u003e0),\n        'login': 'kolypto',\n        'name': 'Mark',\n    }\n}\n```\n\nIf you actually want to use the nested value in some more complicated context, you can actually\n*capture the value* using [Parameter()](apiens/testing/object_match/parameter.py):\nthis object captures the value while being compared to it:\n\n```python\nres = execute_api_request()\nassert res == {\n    'user': {\n        # Capture the value into a variable\n        'id': (user_id := Parameter()),\n        'login': 'kolypto',\n        'name': 'Mark',\n    }\n}\n\n# Use the value\nassert user_id.value \u003e 0\nprint(user_id.value)\n```\n\nIf your trouble is not about one value but rather about ignoring a whole bunch of dict keys,\nuse [`DictMatch`](apiens/testing/object_match/dict_match.py) for partial dict matching:\n\n```python\nres = execute_api_request()\nassert res == {\n    # Partial dict match: only named keys are compared\n    'user': DictMatch({\n        'login': 'kolypto',\n        'name': 'Mark',\n    })\n}\n```\n\nThere also is [`ObjectMatch`](apiens/testing/object_match/object_match.py) for parial object matching:\nit only inspects the attributes that you've named, ignoring all the rest:\n\n```python\n# Create an object with some attributes\nfrom collections import namedtuple\nPoint = namedtuple('Point', ('x', 'y'))\npoint = Point(0, 100)\n\n# Only inspect the 'x' attribute\nassert point == ObjectMatch(x=0)\n```\n\n\n\n\n\n\n### Model Match\nThis module will help you test that your database models actually match your GraphQL definitions\nand your Pydantic validation schemas. Turns out, it's so easy to make a typo in field names,\nunderscores, camel cases, and especially, nullable and non-nullable fields!\n\nSuppose you have to models: a SqlAlchemy database model of a `User`:\n\n```python\nclass User(Base):\n    id = sa.Column(sa.Integer, primary_key=True)\n    login = sa.Column(sa.String)\n    password = sa.Column(sa.String)\n```\n\nand some Pydantic representation of this model, with one field missing:\n\n```python\nclass UserSchema(pd.BaseModel):\n    id: int \n    login: Optional[str]\n    # password: not included!\n```\n\nHere's how you can compare such models to make sure you've made no typos.\nFirst, convert every model to some intermediate shape for matching\nusing [`model_match.match()`](apiens/testing/model_match/match.py):\n\n```python\n# Convert every model to its intermediate shape\ndb_user = model_match.match(User)\npd_user = model_match.match(UserSchema)\n\nprint(str(db_user))\n# -\u003e id: !nullable required ; \n# -\u003e login: !required nullable ; \n# -\u003e password: !required nullable\n```\n\nYou can immediately compare the two models, but note the missing field.\nWe need to exclude it first. This is achieved using the `select_fields()` helper \nthat generates a new model:\n\n```python\n# Compare DB `User` model to Pydantic `UserSchema`    \nassert pd_user == model_match.select_fields(\n    # Exclude a few fields from comparison\n    db_user,\n    model_match.exclude('password'),\n)\n```\n\nNow, if during development someone adds a field to the database model, your unit-tests would fail\nand remind the developer that they need to update their pydantic schemas as well.\n\nThis is quite useful in large teams because keeping models up to date takes discipline,\nand we humans always fail at discipline :) \n\nThis [`model_match`](apiens/testing/model_match/) tool can also rewrite field names into,\nsay, camelCase, thus supporting your GraphQL models.\nHave a look at the code: it has you covered.\n\n\n\n\n\n\n## SqlAlchemy tools\nThis section of the docs is under development.\nPlease have a look at the [apiens.tools.sqlalchemy](apiens/tools/sqlalchemy/) module.\n\nUndocumented features include:\n\n* tools.sqlalchemy.testing, conftest\n* tools.sqlalchemy.commit.commit.session_disable_commit, session_enable_commit, session_flush_instead_of_commit\n* tools.sqlalchemy.commit.transaction.db_transaction\n* tools.sqlalchemy.commit.save.db_flush(), db_save(), db_save_refresh(), session_safe_commit(), refresh_instances()\n* tools.sqlalchemy.commit.expire.no_expire_on_commit(), commit_no_expire()\n* tools.sqlalchemy.instance.instance_history_proxy\n* tools.sqlalchemy.instance.modified_attrs: modified_attribute_names, modified_column_attribute_names\n* tools.sqlalchemy.session.session_tracking, ssn_later\n* tools.sqlalchemy.session.ssn_later\n* tools.sqlalchemy.loadopt.raiseload_in_testing\n* tools.sqlalchemy.types.enum\n* error.converting.sqlalchemy\n\n\n\n\n\n\n## Python Tools\nThis section of the docs is under development.\nPlease have a look at the [apiens.tools.python](apiens/tools/python/) module.\n\nUndocumented features include:\n\n* tools.python.lazy_init\n* tools.python.named_exit_stack\n* tools.python.threadpool\n\n\n\n\n\n\n## Structuring Tools\nThis section of the docs is under development.\nPlease have a look at the [apiens.structure](apiens/structure/) module.\n\nUndocumented features include:\n\n* structure.titled enum\n* structure.func.documented_errors\n* structure.func.simple_function\n\n\n\n\n\n\n## Pydantic Tools\nThis section of the docs is under development.\nPlease have a look at the [apiens.tools.pydantic](apiens/tools/pydantic/) module.\n\nUndocumented features include:\n\n* tools.pydantic.derive\n* tools.pydantic.partial\n\n\n\n\n\n\n## Web Tools\nThis section of the docs is under development.\nPlease have a look at the [apiens.tools.web](apiens/tools/web/) module.\n\nUndocumented features include:\n\n* tools.web.jwt_token\n* tools.web.shortid\n\n\n\n\n\n\n## Advanced GraphQL Tools\nThis section of the docs is under development.\nPlease have a look at the [apiens.tools.graphql](apiens/tools/graphql/) module.\n\nUndocumented features include:\n\n* tools.graphql.directives.*\n* tools.graphql.resolver.resolve\n* tools.graphql.scalars.date\n* tools.graphql.schema.ast\n* tools.graphql.schema.input_types\n* tools.graphql.directives.*\n\n\n\n\n\n\n## Advanced FastAPI Tools\nThis section of the docs is under development.\nPlease have a look at the [apiens.tools.fastapi](apiens/tools/fastapi/) module.\n\nUndocumented features include:\n\n* tools.fastapi.class_based_view\n\n\n\n\n\n\n## Advanced Ariadne Tools\nThis section of the docs is under development.\nPlease have a look at the [apiens.tools.ariadne](apiens/tools/ariadne/) module.\n\nUndocumented features include:\n\n* tools.ariadne.directive\n* tools.ariadne.errors\n* tools.ariadne.resolver\n* tools.ariadne.scalars\n* tools.ariadne.schema\n* tools.ariadne.testing\n\n\n\n\n\n\nCRUD APIs\n=========\nThis section of the docs is under development.\nPlease have a look at the [apiens.tools.crud](apiens/crud/) module.\n\nUndocumented features include:\n\n* crud.query\n* crud.mutate\n* crud.signals\n* crud.settings\n* error: converting jessiql, converting apiens\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkolypto%2Fpy-apiens","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fkolypto%2Fpy-apiens","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkolypto%2Fpy-apiens/lists"}