Giter Club home page Giter Club logo

turbulette's Introduction

Turbulette

test codacy-coverage codacy-grade pypi py-version license mypy black bandit pre-commit gitter netlify

Turbulette packages all you need to build great GraphQL APIs :

ASGI framework, GraphQL library, ORM and data validation


Documentation : https://turbulette.netlify.app


Features :

  • Split your API in small, independent applications
  • Generate Pydantic models from GraphQL types
  • JWT authentication with refresh and fresh tokens
  • Declarative, powerful and extendable policy-based access control (PBAC)
  • Extendable auth user model with role management
  • Async caching (provided by async-caches)
  • Built-in CLI to manage project, apps, and DB migrations
  • Built-in pytest plugin to quickly test your resolvers
  • Settings management at project and app-level (thanks to simple-settings)
  • CSRF middleware
  • 100% test coverage
  • 100% typed, your IDE will thank you ;)
  • Handcrafted with โค๏ธ, from ๐Ÿ‡ซ๐Ÿ‡ท

Requirements

Python 3.6+

๐Ÿ‘ Turbulette makes use of great tools/frameworks and wouldn't exist without them :

  • Ariadne - Schema-first GraphQL library
  • Starlette - The little ASGI framework that shines
  • GINO - Lightweight, async ORM
  • Pydantic - Powerful data validation with type annotations
  • Alembic - Lightweight database migration tool
  • simple-settings - A generic settings system inspired by Django's one
  • async-caches - Async caching library
  • Click - A "Command Line Interface Creation Kit"

Installation

pip install turbulette

You will also need an ASGI server, such as uvicorn :

pip install uvicorn

๐Ÿš€ Quick Start

Here is a short example that demonstrates a minimal project setup.

We will see how to scaffold a simple Turbulette project, create a Turbulette application, and write some GraphQL schema/resolver. It's advisable to start the project in a virtualenv to isolate your dependencies. Here we will be using poetry :

poetry init

Then, install Turbulette from PyPI :

poetry add turbulette

For the rest of the tutorial, we will assume that commands will be executed under the virtualenv. To spawn a shell inside the virtualenv, run :

poetry shell

1: Create a project

First, create a directory that will contain the whole project.

Now, inside this folder, create your Turbulette project using the turb CLI :

turb project eshop

You should get with something like this :

.
โ””โ”€โ”€ ๐Ÿ“ eshop
    โ”œโ”€โ”€ ๐Ÿ“ alembic
    โ”‚   โ”œโ”€โ”€ ๐Ÿ“„ env.py
    โ”‚   โ””โ”€โ”€ ๐Ÿ“„ script.py.mako
    โ”œโ”€โ”€ ๐Ÿ“„ .env
    โ”œโ”€โ”€ ๐Ÿ“„ alembic.ini
    โ”œโ”€โ”€ ๐Ÿ“„ app.py
    โ””โ”€โ”€ ๐Ÿ“„ settings.py

Let's break down the structure :

  • ๐Ÿ“ eshop : Here is the so-called Turbulette project folder, it will contain applications and project-level configuration files
  • ๐Ÿ“ alembic : Contains the Alembic scripts used when generating/applying DB migrations
    • ๐Ÿ“„ env.py
    • ๐Ÿ“„ script.py.mako
  • ๐Ÿ“„ .env : The actual project settings live here
  • ๐Ÿ“„ app.py : Your API entrypoint, it contains the ASGI app
  • ๐Ÿ“„ settings.py : Will load settings from .env file

Why have both .env and settings.py?

You don't have to. You can also put all your settings in settings.py. But Turbulette encourage you to follow the twelve-factor methodology, that recommend to separate settings from code because config varies substantially across deploys, code does not. This way, you can untrack .env from version control and only keep tracking settings.py, which will load settings from .env using Starlette's Config object.

2: Create the first app

Now it's time to create a Turbulette application!

Run this command under the project directory (eshop) :

turb app --name account

You need to run turb app under the project dir because the CLI needs to access the almebic.ini file to create the initial database migration.

You should see your new app under the project folder :

.
โ””โ”€โ”€ ๐Ÿ“ eshop
    ...
    |
    โ””โ”€โ”€ ๐Ÿ“ account
        โ”œโ”€โ”€ ๐Ÿ“ graphql
        โ”œโ”€โ”€ ๐Ÿ“ migrations
        โ”‚   โ””โ”€โ”€ ๐Ÿ“„ 20200926_1508_auto_ef7704f9741f_initial.py
        โ”œโ”€โ”€ ๐Ÿ“ resolvers
        โ””โ”€โ”€ ๐Ÿ“„ models.py

Details :

  • ๐Ÿ“ graphql : All the GraphQL schema will live here
  • ๐Ÿ“ migrations : Will contain database migrations generated by Alembic
  • ๐Ÿ“ resolvers : Python package where you will write resolvers binded to the schema
  • ๐Ÿ“„ models.py : Will hold GINO models for this app

What is this "initial" python file under ๐Ÿ“ migrations?

We won't cover database connection in this quickstart, but note that it's the initial database migration for the account app that creates its dedicated Alembic branch, needed to generate/apply per-app migrations.

Before writing some code, the only thing to do is make Turbulette aware of our lovely account app.

To do this, open ๐Ÿ“„ eshop/settings.py and add "eshop.account" to INSTALLED_APPS, so the application is registered and can be picked up by Turbulette at startup :

# List installed Turbulette apps that defines some GraphQL schema
INSTALLED_APPS = ["eshop.account"]

3: GraphQL schema

Now that we have our project scaffold, we can start writing actual schema/code.

Create a schema.gql file in the ๐Ÿ“ graphql folder and add this base schema :

extend type Mutation {
    registerCard(input: CreditCard!): SuccessOut!
}

input CreditCard {
    number: String!
    expiration: Date!
    name: String!
}

type SuccessOut {
    success: Boolean
    errors: [String]
}

Note that we extend the type Mutation because Turbulette already defines it. The same goes for Query type

Notice that with use the Date scalar, it's one of the custom scalars provided by Turbulette. It parses string in the ISO8601 date format YYY-MM-DD.

4: Add pydantic model

We want to validate our CreditCard input to ensure the user has entered a valid card number and date. Fortunately, Turbulette integrates with Pydantic, a data validation library that uses python type annotations, and offers a convenient way to generate a Pydantic model from a schema type.

Create a new ๐Ÿ“„ pyd_models.py under ๐Ÿ“ account :

from turbulette.validation import GraphQLModel
from pydantic import PaymentCardNumber


class CreditCard(GraphQLModel):
    class GraphQL:
        gql_type = "CreditCard"
        fields = {"number": PaymentCardNumber}

What's happening here?

The inherited GraphQLModel class is a pydantic model that knows about the GraphQL schema and can produce pydantic fields from a given GraphQL type. We specify the GraphQL type with the gql_type attribute; it's the only one required.

But we also add a fields attribute to override the type of number field because it is string typed in our schema. If we don't add this, Turbulette will assume that number is a string and will annotate the number field as str. fields is a mapping between GraphQL field names and the type that will override the schema's one.

Let's add another validation check: the expiration date. We want to ensure the user has entered a valid date (i.e., at least greater than now) :

from datetime import datetime
from pydantic import PaymentCardNumber
from turbulette.validation import GraphQLModel, validator


class CreditCard(GraphQLModel):
    class GraphQL:
        gql_type = "CreditCard"
        fields = {"number": PaymentCardNumber}

    @validator("expiration")
    def check_expiration_date(cls, value):
        if value < datetime.now():
            raise ValueError("Expiration date is invalid")
        return value

Why don't we use the @validator from Pydantic?

For those who have already used Pydantic, you probably know about the @validator decorator used add custom validation rules on fields.

But here, we use a @validator imported from turbulette.validation, why?

They're almost identical. Turbulette's validator is just a shortcut to the Pydantic one with check_fields=False as a default, instead of True, because we use an inherited BaseModel. The above snippet would correctly work if we used Pydantic's validator and explicitly set @validator("expiration", check_fields=False).

5: Add a resolver

The last missing piece is the resolver for our user mutation, to make the API returning something when querying for it.

The GraphQL part is handled by Ariadne, a schema-first GraphQL library that allows binding the logic to the schema with minimal code.

As you may have guessed, we will create a new Python module in our ๐Ÿ“ resolvers package.

Let's call it ๐Ÿ“„ user.py :

from turbulette import mutation
from ..pyd_models import CreditCard

@mutation.field("registerCard")
async def register(obj, info, **kwargs):
    return {"success": True}

mutation is the base mutation type defined by Turbulette and is used to register all mutation resolvers (hence the use of extend type Mutation on the schema). For now, our resolver is very simple and doesn't do any data validation on inputs and doesn't handle errors.

Turbulette has a @validate decorator that can be used to validate resolver input using a pydantic model (like the one defined in Step 4).

Here's how to use it:

from turbulette import mutation
from ..pyd_models import CreditCard
from turbulette.validation import validate

@mutation.field("registerCard")
@validate(CreditCard)
async def register(obj, info, **kwargs):
    return {"success": True}

If the validation succeeds, you can access the validated input data in kwargs["_val_data"] But what happens otherwise? Normally, if the validation fails, pydantic will raise a ValidationError, but here the @validate decorator handles the exception and will add error messages returned by pydantic into a dedicated error field in the GraphQL response.

5: Run it

Our registerCard mutation is now binded to the schema, so let's test it.

Start the server in the root directory (the one containing ๐Ÿ“ eshop folder) :

uvicorn eshop.app:app --port 8000

Now, go to http://localhost:8000/graphql, you will see the GraphQL Playground IDE. Finally, run the registerCard mutation, for example :

mutation card {
  registerCard(
    input: {
      number: "4000000000000002"
      expiration: "2023-05-12"
      name: "John Doe"
    }
  ) {
    success
    errors
  }
}

Should give you the following expected result :

{
  "data": {
    "registerCard": {
      "success": true,
      "errors": null
    }
  }
}

Now, try entering a wrong date (before now). You should see the validation error as expected:

{
  "data": {
    "registerCard": {
      "success": null,
      "errors": [
        "expiration: Expiration date is invalid"
      ]
    }
  }
}

How the error message end in the errors key?

Indeed, we didn't specify anywhere that validation errors should be passed to the errors key in our SuccessOut GraphQL type. That is because Turbulette has a setting called ERROR_FIELD, which defaults to "errors". This setting indicates the error field on the GraphLQ output type used by Turbulette when collecting query errors.

It means that if you didn't specify ERROR_FIELD on the GraphQL type, you would get an exception telling you that the field is missing.

It's the default (and recommended) way of handling errors in Turbulette. Still, as all happens in the @validate, you can always remove it and manually instantiate your Pydantic models in resolvers.

Good job! ๐Ÿ‘

That was a straightforward example, showing off a simple Turbulette API set up. To get the most of it, follow the User Guide .

turbulette's People

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar

turbulette's Issues

Documentation example is not working

Describe the bug
The example is not working: when I follow all the steps (or so I believe...) I get this error:

ValueError: Field registerCard is not defined on type Mutation

To Reproduce
Steps to reproduce the behavior: follow the quick start guide

Expected behavior
I expect no error and to be able to connect to the graphql playground to test the example.

Environment
poetry env as defined on the turbulette example under macOS

  • Explain with a simple sentence the expected behavior
    A working example

  • Turbulette version:

(master-njb6Mycc-py3.8) bash-3.2$ poetry show turbulette
name         : turbulette
version      : 0.4.0
description  : A batteries-included framework to build high performance, async GraphQL APIs

dependencies
 - alembic >=1.4.2,<2.0.0
 - ariadne >=0.11,<0.13
 - async-caches >=0.3.0,<0.4.0
 - ciso8601 >=2.1.3,<3.0.0
 - click >=7.1.2,<8.0.0
 - gino >=1.0.1,<2.0.0
 - passlib >=1.7.2,<2.0.0
 - psycopg2 >=2.8.5,<3.0.0
 - pydantic >=1.6.1,<2.0.0
 - python-jwt >=3.2.6,<4.0.0
 - simple-settings >=0.19.1,<1.1.0
  • Python version:
    Python 3.8

  • Executed in docker:
    No

  • Dockerfile sample:
    NA

  • GraphQL Schema & Query:

extend type Mutation {
    registerCard(input: CreditCard!): SuccessOut!
}

input CreditCard {
    number: String!
    expiration: Date!
    name: String!
}

type SuccessOut {
    success: Boolean
    errors: [String]
}

  • Is it a regression from a previous versions?
    No

  • Stack trace

(master-njb6Mycc-py3.8) bash-3.2$ uvicorn backend.app:app --port 8000
Traceback (most recent call last):
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/bin/uvicorn", line 8, in <module>
    sys.exit(main())
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/click/core.py", line 829, in __call__
    return self.main(*args, **kwargs)
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/click/core.py", line 782, in main
    rv = self.invoke(ctx)
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/click/core.py", line 1066, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/click/core.py", line 610, in invoke
    return callback(*args, **kwargs)
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/uvicorn/main.py", line 357, in main
    run(**kwargs)
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/uvicorn/main.py", line 381, in run
    server.run()
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/uvicorn/_impl/asyncio.py", line 47, in run
    loop.run_until_complete(self.serve(sockets=sockets))
  File "/usr/local/opt/[email protected]/Frameworks/Python.framework/Versions/3.8/lib/python3.8/asyncio/base_events.py", line 616, in run_until_complete
    return future.result()
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/uvicorn/_impl/asyncio.py", line 54, in serve
    config.load()
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/uvicorn/config.py", line 306, in load
    self.loaded_app = import_from_string(self.app)
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/uvicorn/importer.py", line 20, in import_from_string
    module = importlib.import_module(module_str)
  File "/usr/local/opt/[email protected]/Frameworks/Python.framework/Versions/3.8/lib/python3.8/importlib/__init__.py", line 127, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
  File "<frozen importlib._bootstrap>", line 1014, in _gcd_import
  File "<frozen importlib._bootstrap>", line 991, in _find_and_load
  File "<frozen importlib._bootstrap>", line 975, in _find_and_load_unlocked
  File "<frozen importlib._bootstrap>", line 671, in _load_unlocked
  File "<frozen importlib._bootstrap_external>", line 783, in exec_module
  File "<frozen importlib._bootstrap>", line 219, in _call_with_frames_removed
  File "./backend/app.py", line 5, in <module>
    app = turbulette_starlette()
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/turbulette/asgi.py", line 106, in turbulette_starlette
    graphql_route = setup(project_settings, is_database)
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/turbulette/main.py", line 41, in setup
    schema = registry.setup()
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/turbulette/apps/registry.py", line 150, in setup
    executable_schema = make_schema(
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/ariadne/executable_schema.py", line 35, in make_executable_schema
    bindable.bind_to_schema(schema)
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/ariadne/objects.py", line 41, in bind_to_schema
    self.bind_resolvers_to_graphql_type(graphql_type)
  File "/Users/master/Library/Caches/pypoetry/virtualenvs/backend-njb6Mycc-py3.8/lib/python3.8/site-packages/ariadne/objects.py", line 55, in bind_resolvers_to_graphql_type
    raise ValueError(
ValueError: Field registerCard is not defined on type Mutation

  • Other
    Additionnaly, mypy whines on this line:
from ..pyd_models import CreditCard

-> Relative import climbs too many namespaces mypy(error)

Waiting for agnostic version

I look forward to agnostic turbulette to test it with mongodb/motor, I have done some personal tests but since you are going to release a version to be able to use it with all databases, I better hope.
you use fastapi in turbulette or just starlette?

can I use motor(mongodb) with this?

I find this project very interesting, I have been looking for something similar but that can use mongodb as a database.

Is it possible to use motor for mongodb with turbulette?

relation "account_base_user" does not exist

Hi, I'm a newbie, but I am very interested in this project because I am looking for starlette with jwt authentication, and this is more than expected.

But I have some issues when I am using one of examples: createUser.
I installed postgres, and I also set up database connection in .env.
I just thought GINO will automatically create databases and its tables from models.py, but there is no db and no tables.
The below is what I am going through.
Am I missing something related to database?

Traceback (most recent call last):
  File "/usr/local/lib/python3.6/dist-packages/graphql/execution/execute.py", line 674, in await_completed
    return await completed
  File "/usr/local/lib/python3.6/dist-packages/graphql/execution/execute.py", line 659, in await_result
    return_type, field_nodes, info, path, await result
  File "/usr/local/lib/python3.6/dist-packages/graphql/execution/execute.py", line 733, in complete_value
    raise result
  File "/usr/local/lib/python3.6/dist-packages/graphql/execution/execute.py", line 628, in await_result
    return await result
  File "/usr/local/lib/python3.6/dist-packages/ariadne/types.py", line 58, in resolve
    result = await result
  File "/usr/local/lib/python3.6/dist-packages/ariadne/utils.py", line 45, in async_wrapper
    return await func(*args, **convert_to_snake_case(kwargs))
  File "/usr/local/lib/python3.6/dist-packages/turbulette/validation/decorators.py", line 35, in wrapped_func
    return await func(obj, info, **kwargs)
  File "./eshop/account/resolvers/user.py", line 38, in resolve_user_create
    user_model.username == valid_input["username"]
  File "/usr/local/lib/python3.6/dist-packages/gino/api.py", line 137, in first
    return await self._query.bind.first(self._query, *multiparams, **params)
  File "/usr/local/lib/python3.6/dist-packages/gino/engine.py", line 748, in first
    return await conn.first(clause, *multiparams, **params)
  File "/usr/local/lib/python3.6/dist-packages/gino/engine.py", line 328, in first
    return await result.execute(one=True)
  File "/usr/local/lib/python3.6/dist-packages/gino/dialects/base.py", line 215, in execute
    context.statement, context.timeout, args, 1 if one else 0
  File "/usr/local/lib/python3.6/dist-packages/gino/dialects/asyncpg.py", line 184, in async_execute
    result, stmt = await getattr(conn, "_do_execute")(query, executor, timeout)
  File "/usr/local/lib/python3.6/dist-packages/asyncpg/connection.py", line 1681, in _do_execute
    ignore_custom_codec=ignore_custom_codec,
  File "/usr/local/lib/python3.6/dist-packages/asyncpg/connection.py", line 380, in _get_statement
    ignore_custom_codec=ignore_custom_codec,
  File "asyncpg/protocol/protocol.pyx", line 168, in prepare
graphql.error.graphql_error.GraphQLError: relation "account_base_user" does not exist

modles.py

from gino.json_support import ObjectProperty
from sqlalchemy import Column, DateTime, ForeignKey, Integer
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.sql.sqltypes import Float, String

from turbulette.db import Model
from turbulette.apps.auth.models import AbstractUser


class BaseUser(Model, AbstractUser):
    pass


class CustomUser(AbstractUser, Model):
    pass

resolvers/user.py

@mutation.field("createUser")
@convert_kwargs_to_snake_case
@validate(BaseUserCreate)
async def resolve_user_create(*_, **kwargs) -> dict:
    valid_input = kwargs["_val_data"]

    user = await user_model.query.where(
        user_model.username == valid_input["username"]
    ).gino.first()

    if user:
        message = f"User {valid_input['username']} already exists"

        # Make sure to call __str__ on BaseError
        out = str(ErrorField(message))
        logging.info(out)

        return ErrorField(message).dict()

    new_user = await user_model.create(**valid_input)
    auth_token = await get_token_from_user(new_user)
    return {
        "user": {**new_user.to_dict()},
        "token": auth_token,
    }

Poetry Add fails on Psycopg2 dependency

Describe the bug
On running poetry add turbulette as described in the readme the command errors on trying to install psycopg2 from the pyproject.toml dependency tree.

To Reproduce
Run poetry add turbulette

Expected behavior
The command would ideally run to add turbulette to pyproject.toml

Screenshots
If applicable, add screenshots to help explain your problem.

Environment

If possible, a full reproducible environment (ex: a Dockerfile)

  • Explain with a simple sentence the expected behavior
  • Turbulette version: e.g 0.1.0
  • Python version: e.g 3.6
  • Executed in docker: Yes|No
  • Dockerfile sample: Link of sample
  • GraphQL Schema & Query: e.g gist, pastebin or directly the query
  • Is it a regression from a previous versions? e.g Yes|No
  • Stack trace

Additional context
Add any other context about the problem here.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.