Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added a new example using Falcon and graphene-sqlalchemy #166

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -108,6 +108,7 @@ To learn more check out the following [examples](examples/):

- [Flask SQLAlchemy example](examples/flask_sqlalchemy)
- [Nameko SQLAlchemy example](examples/nameko_sqlalchemy)
- [Falcon SQLAlchemy example](examples/falcon_sqlalchemy)

## Contributing

6 changes: 5 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
@@ -75,8 +75,12 @@ Then you can simply query the schema:
To learn more check out the following `examples <examples/>`__:

- **Full example**: `Flask SQLAlchemy
- **Full example (Flask)**: `Flask SQLAlchemy
example <examples/flask_sqlalchemy>`__
- **Full example (Nameko)**: `Nameko SQLAlchemy
example <examples/nameko_sqlalchemy>`__
- **Full example (Falcon)**: `Falcon SQLAlchemy
example <examples/falcon_sqlalchemy>`__

Contributing
------------
313 changes: 313 additions & 0 deletions examples/falcon_sqlalchemy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,313 @@
# demo-graphql-sqlalchemy-falcon

## Overview

This is a simple project demonstrating the implementation of a GraphQL server in Python using:

- [SQLAlchemy](https://github.com/zzzeek/sqlalchemy).
- [Falcon](https://github.com/falconry/falcon).
- [Graphene](https://github.com/graphql-python/graphene).
- [Graphene-SQLAlchemy](https://github.com/graphql-python/graphene-sqlalchemy).
- [Gunicorn](https://github.com/benoitc/gunicorn).

The objective is to demonstrate how these different libraries can be integrated.

## Features

The primary feature offered by this demo are:

- [SQLAlchemy](https://github.com/zzzeek/sqlalchemy) ORM against a local SQLite database. The ORM is super simple but showcases a many-to-many relationship between `authors` and `books` via an `author_books` association table.
- [Falcon](https://github.com/falconry/falcon) resources to serve both [GraphQL](https://github.com/facebook/graphql) and [GraphiQL](https://github.com/graphql/graphiql).

> The [Falcon](https://github.com/falconry/falcon) resources are slightly modified versions of the ones under [https://github.com/alecrasmussen/falcon-graphql-server](https://github.com/alecrasmussen/falcon-graphql-server) so all credits to [Alec Rasmussen](https://github.com/alecrasmussen).
- Basic [GraphQL](https://github.com/facebook/graphql) schema automatically derived from the [SQLAlchemy](https://github.com/zzzeek/sqlalchemy) ORM via [Graphene](https://github.com/graphql-python/graphene) and [Graphene-SQLAlchemy](https://github.com/graphql-python/graphene-sqlalchemy).
- API setup via [Falcon](https://github.com/falconry/falcon) with the whole thing served via [Gunicorn](https://github.com/benoitc/gunicorn).

## Usage

All instructions and commands below are meant to be run from the root dir of this repo.

### Prerequisites

You are strongly encouraged to use a virtualenv here but I can be assed writing down the instructions for that.

Install all requirements through:

```
pip install -r requirements.txt
```

### Sample Database

The sample SQLite database has been committed in this repo but can easily be rebuilt through:

```
python -m demo.orm
```

at which point it will create a `demo.db` in the root of this repo.

> The sample data are defined under `data.py` while they're ingested with the code under the `main` sentinel in `orm.py`. Feel free to tinker.
### Running Server

The [Gunicorn](https://github.com/benoitc/gunicorn) is configured via the `gunicorn_config.py` module and binds by default to `localhost:5432/`. You can change all gunicorn configuration options under the aforementioned module.

The server can be run through:

```
gunicorn -c gunicorn_config.py "demo.demo:main()"
```

The server exposes two endpoints:

- `/graphql`: The standard GraphQL endpoint which can receive the queries directly (accessible by default under [http://localhost:5432/graphql](http://localhost:5432/graphql)).
- `/graphiql`: The [GraphiQL](https://github.com/graphql/graphiql) interface (accessible by default under [http://localhost:5432/graphiql](http://localhost:5432/graphiql)).

### Queries

Here's a couple example queries you can either run directly in [GraphiQL](https://github.com/graphql/graphiql) or by performing POST requests against the [GraphQL](https://github.com/facebook/graphql) server.

#### Get an author by ID

Query:

```
query getAuthor{
author(authorId: 1) {
nameFirst,
nameLast
}
}
```

Response:

```
{
"data": {
"author": {
"nameFirst": "Robert",
"nameLast": "Jordan"
}
}
}
```

#### Get an author by first name

```
query getAuthor{
author(nameFirst: "Robert") {
nameFirst,
nameLast
}
}
```

Response:

```
{
"data": {
"author": {
"nameFirst": "Robert",
"nameLast": "Jordan"
}
}
}
```

### Get an author and their books

Query:

```
query getAuthor{
author(nameFirst: "Brandon") {
nameFirst,
nameLast,
books {
title,
year
}
}
}
```

Response:

```
{
"data": {
"author": {
"nameFirst": "Brandon",
"nameLast": "Sanderson",
"books": [
{
"title": "The Gathering Storm",
"year": 2009
},
{
"title": "Towers of Midnight",
"year": 2010
},
{
"title": "A Memory of Light",
"year": 2013
}
]
}
}
}
```

#### Get books by year

Query:

```
query getBooks{
books(year: 1990) {
title,
year
}
}
```

Response:

```
{
"data": {
"books": [
{
"title": "The Eye of the World",
"year": 1990
},
{
"title": "The Great Hunt",
"year": 1990
}
]
}
}
```

#### Get books and their authors by their title

Query:

```
query getBooks{
books(title: "A Memory of Light") {
title,
year,
authors {
nameFirst,
nameLast
}
}
}
```

Response:

```
{
"data": {
"books": [
{
"title": "A Memory of Light",
"year": 2013,
"authors": [
{
"nameFirst": "Robert",
"nameLast": "Jordan"
},
{
"nameFirst": "Brandon",
"nameLast": "Sanderson"
}
]
}
]
}
}
```

#### Get number of books by cover-artist

Query:

```
query getCountBooksByCoverArtist{
stats {
countBooksByCoverArtist {
coverArtist,
countBooks
}
}
}
```

Response:

```
{
"data": {
"stats": {
"countBooksByCoverArtist": [
{
"coverArtist": null,
"countBooks": 1
},
{
"coverArtist": "Darrell K. Sweet",
"countBooks": 12
},
{
"coverArtist": "Michael Whelan",
"countBooks": 1
}
]
}
}
}
```

#### Add new author

Query:

```
mutation createAuthor{
createAuthor(author: {
nameFirst: "First Name",
nameLast: "Last Name"
}) {
author {
authorId
nameFirst
nameLast
}
}
}
```

Response:

```
{
"data": {
"createAuthor": {
"author": {
"authorId": "3",
"nameFirst": "First Name",
"nameLast": "Last Name"
}
}
}
}
```
Binary file added examples/falcon_sqlalchemy/demo.db
Binary file not shown.
11 changes: 11 additions & 0 deletions examples/falcon_sqlalchemy/demo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# coding=utf-8

from demo import api
from demo import data
from demo import demo
from demo import orm
from demo import orm_base
from demo import resources
from demo import schema
from demo import schema_types
from demo import utils
41 changes: 41 additions & 0 deletions examples/falcon_sqlalchemy/demo/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# coding=utf-8

import sqlalchemy.orm
import falcon
import graphene

from demo.resources import ResourceGraphQlSqlAlchemy
from demo.resources import ResourceGraphiQL


def create_app(
schema: graphene.Schema,
scoped_session: sqlalchemy.orm.scoped_session,
do_enable_graphiql: bool,
):
# Create the API.
app = falcon.API()

app.add_route(
uri_template="/graphql",
resource=ResourceGraphQlSqlAlchemy(
schema=schema,
scoped_session=scoped_session,
)
)

if do_enable_graphiql:
app.add_route(
uri_template="/graphiql/",
resource=ResourceGraphiQL(
path_graphiql="graphiql",
)
)
app.add_route(
uri_template="/graphiql/{static_file}",
resource=ResourceGraphiQL(
path_graphiql="graphiql",
)
)

return app
130 changes: 130 additions & 0 deletions examples/falcon_sqlalchemy/demo/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# coding=utf-8

from __future__ import unicode_literals

authors = {
"data": [
{
"author_id": 1,
"name_first": "Robert",
"name_last": "Jordan",
},
{
"author_id": 2,
"name_first": "Brandon",
"name_last": "Sanderson",
}
]
}

books = {
"data": [
{
"book_id": 1,
"title": "The Eye of the World",
"year": 1990,
"cover_artist": "Darrell K. Sweet",
},
{
"book_id": 2,
"title": "The Great Hunt",
"year": 1990,
"cover_artist": "Darrell K. Sweet",
},
{
"book_id": 3,
"title": "The Dragon Reborn",
"year": 1991,
"cover_artist": "Darrell K. Sweet",
},
{
"book_id": 4,
"title": "The Shadow Rising",
"year": 1992,
"cover_artist": "Darrell K. Sweet",
},
{
"book_id": 5,
"title": "The Fires of Heaven",
"year": 1993,
"cover_artist": "Darrell K. Sweet",
},
{
"book_id": 6,
"title": "Lord of Chaos",
"year": 1994,
"cover_artist": "Darrell K. Sweet",
},
{
"book_id": 7,
"title": "A Crown of Swords",
"year": 1996,
"cover_artist": "Darrell K. Sweet",
},
{
"book_id": 8,
"title": "The Path of Daggers",
"year": 1998,
"cover_artist": "Darrell K. Sweet",
},
{
"book_id": 9,
"title": "Winter's Heart",
"year": 2000,
"cover_artist": None,
},
{
"book_id": 10,
"title": "Crossroads of Twilight",
"year": 2003,
"cover_artist": "Darrell K. Sweet",
},
{
"book_id": 11,
"title": "Knife of Dreams",
"year": 2005,
"cover_artist": "Darrell K. Sweet",
},
{
"book_id": 12,
"title": "The Gathering Storm",
"year": 2009,
"cover_artist": "Darrell K. Sweet",
},
{
"book_id": 13,
"title": "Towers of Midnight",
"year": 2010,
"cover_artist": "Darrell K. Sweet",
},
{
"book_id": 14,
"title": "A Memory of Light",
"year": 2013,
"cover_artist": "Michael Whelan",
},
]
}


author_books = {
"data": [
{"author_book_id": 1, "author_id": 1, "book_id": 1},
{"author_book_id": 2, "author_id": 1, "book_id": 2},
{"author_book_id": 3, "author_id": 1, "book_id": 3},
{"author_book_id": 4, "author_id": 1, "book_id": 4},
{"author_book_id": 5, "author_id": 1, "book_id": 5},
{"author_book_id": 6, "author_id": 1, "book_id": 6},
{"author_book_id": 7, "author_id": 1, "book_id": 7},
{"author_book_id": 8, "author_id": 1, "book_id": 8},
{"author_book_id": 9, "author_id": 1, "book_id": 9},
{"author_book_id": 10, "author_id": 1, "book_id": 10},
{"author_book_id": 11, "author_id": 1, "book_id": 11},
{"author_book_id": 12, "author_id": 1, "book_id": 12},
{"author_book_id": 13, "author_id": 1, "book_id": 13},
{"author_book_id": 14, "author_id": 1, "book_id": 14},
{"author_book_id": 15, "author_id": 2, "book_id": 12},
{"author_book_id": 16, "author_id": 2, "book_id": 13},
{"author_book_id": 17, "author_id": 2, "book_id": 14},
]
}
31 changes: 31 additions & 0 deletions examples/falcon_sqlalchemy/demo/demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# coding: utf-8

"""Main module."""

import sqlalchemy.orm

from demo.api import create_app
from demo.schema import schema


def main():

# Create engine to local SQLite database.
engine = sqlalchemy.create_engine("sqlite:///demo.db", echo=True)

# Prepare a DB session.
session_maker = sqlalchemy.orm.sessionmaker(bind=engine)
scoped_session = sqlalchemy.orm.scoped_session(session_maker)

app = create_app(
schema=schema,
scoped_session=scoped_session,
do_enable_graphiql=True,
)

return app


# main sentinel
if __name__ == "__main__":
main()
130 changes: 130 additions & 0 deletions examples/falcon_sqlalchemy/demo/orm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# coding=utf-8

import sqlalchemy.orm

from demo import data
from demo.orm_base import Base, OrmBaseMixin


class Author(Base, OrmBaseMixin):
__tablename__ = "authors"

author_id = sqlalchemy.Column(
sqlalchemy.types.Integer(),
primary_key=True,
)

name_first = sqlalchemy.Column(
sqlalchemy.types.Unicode(length=80),
nullable=False,
)

name_last = sqlalchemy.Column(
sqlalchemy.types.Unicode(length=80),
nullable=False,
)

books = sqlalchemy.orm.relationship(
argument="Book",
secondary="author_books",
back_populates="authors",
)


class Book(Base, OrmBaseMixin):
__tablename__ = "books"

book_id = sqlalchemy.Column(
sqlalchemy.types.Integer(),
primary_key=True,
)

title = sqlalchemy.Column(
sqlalchemy.types.Unicode(length=80),
nullable=False,
)

year = sqlalchemy.Column(
sqlalchemy.types.Integer(),
nullable=False,
)

cover_artist = sqlalchemy.Column(
sqlalchemy.types.Unicode(length=80),
nullable=True,
)

authors = sqlalchemy.orm.relationship(
argument="Author",
secondary="author_books",
back_populates="books",
)


class AuthorBook(Base, OrmBaseMixin):
__tablename__ = "author_books"

author_book_id = sqlalchemy.Column(
sqlalchemy.types.Integer(),
primary_key=True,
)

author_id = sqlalchemy.Column(
sqlalchemy.types.Integer(),
sqlalchemy.ForeignKey("authors.author_id"),
index=True,
)

book_id = sqlalchemy.Column(
sqlalchemy.types.Integer(),
sqlalchemy.ForeignKey("books.book_id"),
index=True,
)


if __name__ == "__main__":

# Create engine to local SQLite database.
engine = sqlalchemy.create_engine("sqlite:///demo.db", echo=True)

# Drop and recreate the entire schema
Base.metadata.drop_all(engine)
Base.metadata.create_all(engine)

# Prepare a DB session.
session_maker = sqlalchemy.orm.sessionmaker(bind=engine)
session = session_maker()

# Create `Author` records.
author_objs = []
for _author_item in data.authors["data"]:
author_obj = Author()
author_obj.author_id = _author_item["author_id"]
author_obj.name_first = _author_item["name_first"]
author_obj.name_last = _author_item["name_last"]
author_objs.append(author_obj)
session.add_all(author_objs)
session.commit()

# Create `Book` authors.
book_objs = []
for _book_item in data.books["data"]:
book_obj = Book()
book_obj.book_id = _book_item["book_id"]
book_obj.title = _book_item["title"]
book_obj.year = _book_item["year"]
book_obj.cover_artist = _book_item["cover_artist"]
book_objs.append(book_obj)
session.add_all(book_objs)
session.commit()

# Create `AuthorBook` records.
author_book_objs = []
for _author_book_item in data.author_books["data"]:
author_book_obj = AuthorBook()
author_book_obj.author_book_id = _author_book_item["author_book_id"]
author_book_obj.author_id = _author_book_item["author_id"]
author_book_obj.book_id = _author_book_item["book_id"]
author_book_objs.append(author_book_obj)
session.add_all(author_book_objs)
session.commit()
239 changes: 239 additions & 0 deletions examples/falcon_sqlalchemy/demo/orm_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
# coding=utf-8

from __future__ import unicode_literals

import inspect
import datetime
import binascii

import sqlalchemy
import sqlalchemy.sql.sqltypes
import sqlalchemy.types
import sqlalchemy.dialects.mysql
from sqlalchemy.ext.declarative import declarative_base
import uuid
from decimal import Decimal


# Create schema metadata with a constraint naming convention so that all
# constraints are named automatically based on the tables and columns they're
# defined upon. This ensures that all constraints will be given a unique name
# regardless of the backend database which allows for `alembic` to create
# comprehensive migrations of the defined schemata.
metadata = sqlalchemy.MetaData(
naming_convention={
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s"
}
)
# create declarative base
Base = declarative_base(metadata=metadata)


class OrmBaseMixin(object):
# take sqla type and value, produce converted value
_sqla_types_convert = {
bytes: lambda t, v: binascii.hexlify(v),
sqlalchemy.types.Binary: lambda t, v: binascii.hexlify(v),
}

_python_instance_convert = {
datetime.datetime: lambda v: v.isoformat() if v else None,
datetime.date: lambda v: v.isoformat() if v else None,
Decimal: lambda v: float(v),
uuid.UUID: lambda v: v.hex,
}

@staticmethod
def _dictify_scalar(scalar, column, serializable=False):
"""Converts scalar values into a serializable format.
Args:
scalar: The value to be converted.
column (sqlalchemy.Column): The SQLAlchemy column the ``scalar``
was stored under.
serializable (bool): Whether to convert ``scalar`` into a format
that can be serialized.
Returns:
The (optionally) serialized version of ``scalar``.
"""

val = scalar

# if data must be serializable, apply conversions into base types
if serializable:
# first check for conversions of the underlying column type
col_type = None
try:
col_type = getattr(column, "type")
except Exception:
# "col" might be a list in the case of a one to many join, skip.
# we'll see it again when the outer loop opens the container
pass
if col_type:
col_type_type = type(col_type)
if col_type_type in OrmBaseMixin._sqla_types_convert:
val = OrmBaseMixin._sqla_types_convert[col_type_type](
col_type,
scalar
)

# Convert (some) complex python types into base types
instance_converters = OrmBaseMixin._python_instance_convert.items()
for instance, converter in instance_converters:
if isinstance(scalar, instance):
val = converter(scalar)
break

return val

def _collect_attributes(self):
"""Handles removal of any meta/internal data that is not from our
underlying table.
Returns:
dict: A dictionary keyed on the field name with the value being a
tuple of the column type and value.
"""

attributes = {}

obj_type = type(self)
column_inspection = sqlalchemy.inspect(obj_type).c
relationship_inspection = sqlalchemy.inspect(obj_type).relationships

for member_name, member_value in self.__dict__.items():
# drop magic sqla keys.
if member_name.startswith("_"):
continue

if (
inspect.isfunction(member_value) or
inspect.ismethod(member_value)
):
continue

if member_name in column_inspection:
member_inspection = column_inspection[member_name]
elif member_name in relationship_inspection:
member_inspection = relationship_inspection[member_name]
else:
continue

attributes[member_name] = (member_inspection, member_value)

return attributes

def to_dict(self, deep=False, serializable=False):
"""Returns a ``dict`` representation of the ORM'ed DB record.
Args:
deep (bool): Whether the perform a recursive conversion of joined
ORM objects and include them into the ``dict``.
serializable (bool): Whether to convert leaf-nodes into a format
that can be serialized.
Returns:
dict: A ``dict`` representation of the ORM'ed DB record.
"""

results = {}

# walk top level
attributes = self._collect_attributes()
for attr_name, (attr_column, attr_value) in attributes.items():

# if value is compound type and deep=True
# recursively collect contents.
if isinstance(attr_value, OrmBaseMixin):
if not deep:
continue
val = attr_value.to_dict(
deep=deep,
serializable=serializable
)

elif isinstance(attr_value, list):
if not deep:
continue

val = []
for sub_attr_value in attr_value:
val.append(sub_attr_value.to_dict(
deep=deep,
serializable=serializable
))

elif isinstance(attr_value, dict):
if not deep:
continue

val = {}
for sub_attr_name, sub_attr_value in attr_value.items():
val[sub_attr_name] = sub_attr_value.to_dict(
deep=deep,
serialisable=serializable
)

# value if scalar, perform any final conversions
else:
val = self._dictify_scalar(
scalar=attr_value,
column=attr_column,
serializable=serializable
)

results[attr_name] = val

return results

def to_string(self, deep=False):
"""Returns a unicode string representation of the ORM'ed DB record.
Args:
deep (bool): Whether the perform a recursive conversion of joined
ORM objects and include them into the string.
Returns:
str: A unicode string representation of the ORM'ed DB record.
"""

attributes = self._collect_attributes()

msg = "<{0}("
for attr_idx, attr_name in enumerate(attributes.keys()):
msg += attr_name + "='{" + str(attr_idx + 1) + "}'"
if attr_idx < len(attributes) - 1:
msg += ", "
msg += ")>"

values = [type(self).__name__]

for attr_name, (attr_column, attr_value) in attributes.items():

if isinstance(attr_value, OrmBaseMixin):
if not deep:
val = "<{0}()>".format(type(attr_value).__name__)
else:
val = attr_value.to_string(deep=deep)
else:
val = self._dictify_scalar(
scalar=attr_value,
column=attr_column,
serializable=True
)

values.append(val)

return msg.format(*values)

def __repr__(self):
"""Returns a unicode string representation of the object
Returns:
unicode: A unicode string representation of the object.
"""
return self.to_string(deep=False)
352 changes: 352 additions & 0 deletions examples/falcon_sqlalchemy/demo/resources.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,352 @@
# coding=utf-8

import os
from os import devnull
import logging
import functools
from collections import OrderedDict
from contextlib import redirect_stdout
import json

import graphene
import falcon


def set_graphql_allow_header(
req: falcon.Request,
resp: falcon.Response,
resource: object,
):
"""Sets the 'Allow' header on responses to GraphQL requests.
Args:
req (falcon.Request): The incoming request.
resp (falcon.Response): The outgoing response.
resource (object): The falcon resource-class associated with the
incoming request.
"""

# Set the `Allow` header to permit given commands.
resp.set_header('Allow', 'GET, POST, OPTIONS')


@falcon.after(set_graphql_allow_header)
class ResourceGraphQl:
"""Main GraphQL server. Integrates with the predefined Graphene schema."""

def __init__(
self,
schema: graphene.Schema,
):

# Internalize arguments.
self.schema = schema

self._respond_invalid_method = functools.partial(
self._respond_error,
status=falcon.HTTP_405,
message="GraphQL only supports GET and POST requests.",
)

self._respond_no_query = functools.partial(
self._respond_error,
status=falcon.HTTP_400,
message="Must provide query string.",
)

self._respond_invalid_variables = functools.partial(
self._respond_error,
status=falcon.HTTP_400,
message="Variables are invalid JSON.",
)

self._respond_invalid_body = functools.partial(
self._respond_error,
status=falcon.HTTP_400,
message="POST body sent invalid JSON.",
)

def _execute_query(
self,
query,
variable_values,
operation_name=None,
):

result = self.schema.execute(
query,
variable_values=variable_values,
operation_name=operation_name
)

return result

@staticmethod
def _respond_error(
resp: falcon.Response,
status: str,
message: str,
):

resp.status = status
resp.body = json.dumps(
{"errors": [{"message": message}]},
separators=(',', ':')
)

def on_options(self, req, resp):
"""Handles OPTIONS requests."""

resp.status = falcon.HTTP_204
pass

def on_head(self, req, resp):
"""Handles HEAD requests. No content."""

pass

def on_get(self, req, resp):
"""Handles GraphQL GET requests."""

if req.params and 'query' in req.params and req.params['query']:
query = str(req.params['query'])
else:
# this means that there aren't any query params in the url
return self._respond_no_query(resp=resp)

if 'variables' in req.params and req.params['variables']:
try:
variables = json.loads(str(req.params['variables']),
object_pairs_hook=OrderedDict)
except json.decoder.JSONDecodeError:
return self._respond_invalid_variables(resp=resp)
else:
variables = ""

if 'operationName' in req.params and req.params['operationName']:
operation_name = str(req.params['operationName'])
else:
operation_name = None

# redirect stdout of schema.execute to /dev/null
with open(devnull, 'w') as f:
with redirect_stdout(f):
# run the query
result = self._execute_query(
query=query,
variable_values=variables,
operation_name=operation_name
)

# construct the response and return the result
if result.data:
data_ret = {'data': result.data}
resp.status = falcon.HTTP_200
resp.body = json.dumps(data_ret, separators=(',', ':'))
return
elif result.errors:
# NOTE: these errors don't include the optional 'locations' key
err_msgs = [{'message': str(i)} for i in result.errors]
resp.status = falcon.HTTP_400
resp.body = json.dumps({'errors': err_msgs}, separators=(',', ':'))
return
else:
# responses should always have either data or errors
raise RuntimeError

def on_post(self, req, resp):
"""Handles GraphQL POST requests."""

# parse url parameters in the request first
if req.params and 'query' in req.params and req.params['query']:
query = str(req.params['query'])
else:
query = None

if 'variables' in req.params and req.params['variables']:
try:
variables = json.loads(str(req.params['variables']),
object_pairs_hook=OrderedDict)
except json.decoder.JSONDecodeError:
return self._respond_invalid_variables(resp=resp)
else:
variables = None

if 'operationName' in req.params and req.params['operationName']:
operation_name = str(req.params['operationName'])
else:
operation_name = None

# Next, handle 'content-type: application/json' requests
if req.content_type and 'application/json' in req.content_type:
# error for requests with no content
if req.content_length in (None, 0):
return self._respond_invalid_body(resp=resp)

# read and decode request body
raw_json = req.stream.read()
try:
req.context['post_data'] = json.loads(
raw_json.decode('utf-8'),
object_pairs_hook=OrderedDict
)
except json.decoder.JSONDecodeError:
return self._respond_invalid_body(resp=resp)

# build the query string (Graph Query Language string)
if (
query is None and req.context['post_data'] and
'query' in req.context['post_data']
):
query = str(req.context['post_data']['query'])
elif query is None:
return self._respond_no_query(resp=resp)

# build the variables string (JSON string of key/value pairs)
if (
variables is None and
req.context['post_data'] and
'variables' in req.context['post_data'] and
req.context['post_data']['variables']
):
try:
variables = req.context['post_data']['variables']
if not isinstance(variables, OrderedDict):
json_str = str(req.context['post_data']['variables'])
variables = json.loads(
json_str,
object_pairs_hook=OrderedDict
)
except json.decoder.JSONDecodeError:
logging.exception(variables)
return self._respond_invalid_variables(resp=resp)

elif variables is None:
variables = ""

# build the operationName string (matches a query or mutation name)
if (
operation_name is None and
'operationName' in req.context['post_data'] and
req.context['post_data']['operationName']
):
operation_name = str(req.context['post_data']['operationName'])

# Alternately, handle 'content-type: application/graphql' requests
elif req.content_type and 'application/graphql' in req.content_type:
# read and decode request body
req.context['post_data'] = req.stream.read().decode('utf-8')

# build the query string
if query is None and req.context['post_data']:
query = str(req.context['post_data'])

elif query is None:
return self._respond_no_query(resp=resp)

# Skip application/x-www-form-urlencoded since they are automatically
# included by setting req_options.auto_parse_form_urlencoded = True

elif query is None:
# this means that the content-type is wrong and there aren't any
# query params in the url
return self._respond_no_query(resp=resp)

# redirect stdout of schema.execute to /dev/null
with open(devnull, 'w') as f:
with redirect_stdout(f):
# run the query
result = self._execute_query(
query=query,
variable_values=variables,
operation_name=operation_name
)

# construct the response and return the result
if result.data:
data_ret = {'data': result.data}
resp.status = falcon.HTTP_200
resp.body = json.dumps(data_ret, separators=(',', ':'))
return
elif result.errors:
# NOTE: these errors don't include the optional 'locations' key
err_msgs = [{'message': str(i)} for i in result.errors]
resp.status = falcon.HTTP_400
resp.body = json.dumps({'errors': err_msgs}, separators=(',', ':'))
return
else:
# responses should always have either data or errors
raise RuntimeError

def on_put(self, req, resp):
"""Handles PUT requests."""

self._respond_invalid_method(resp=resp)

def on_patch(self, req, resp):
"""Handles PATCH requests."""

self._respond_invalid_method(resp=resp)

def on_delete(self, req, resp):
"""Handles DELETE requests."""

self._respond_invalid_method(resp=resp)


@falcon.after(set_graphql_allow_header)
class ResourceGraphQlSqlAlchemy(ResourceGraphQl):
"""Main GraphQL server. Integrates with the predefined Graphene schema."""

def __init__(
self,
schema,
scoped_session,
):
# Internalize arguments.
self.scoped_session = scoped_session

super(ResourceGraphQlSqlAlchemy, self).__init__(schema=schema)

def _execute_query(
self,
query,
variable_values,
operation_name=None,
):
msg_fmt = "Executing query: {} with variables".format(query)
logging.debug(msg_fmt)

result = self.schema.execute(
query,
variable_values=variable_values,
operation_name=operation_name,
context_value={"session": self.scoped_session}
)

return result


class ResourceGraphiQL(object):
"""Serves GraphiQL dashboard. Meant to be used during development only."""

def __init__(
self,
path_graphiql,
):

self.path_graphiql = path_graphiql

def on_get(self, req, resp, static_file=None):
"""Handles GraphiQL GET requests."""

if static_file is None:
static_file = 'graphiql.html'
resp.content_type = 'text/html; charset=UTF-8'
elif static_file == 'graphiql.css':
resp.content_type = 'text/css; charset=UTF-8'
else:
resp.content_type = 'application/javascript; charset=UTF-8'

resp.status = falcon.HTTP_200
resp.stream = open(os.path.join(self.path_graphiql, static_file), 'rb')
107 changes: 107 additions & 0 deletions examples/falcon_sqlalchemy/demo/schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# coding=utf-8

from typing import List, Dict, Union

import graphql
import graphene

from demo.orm import Author
from demo.orm import Book
from demo.schema_types import TypeAuthor
from demo.schema_types import TypeBook
from demo.schema_types import TypeAuthorBook
from demo.schema_types import TypeStats
from demo.schema_types import TypeCountBooksCoverArtist
from demo.schema_mutations import MutationAuthorCreate
from demo.utils import apply_requested_fields


class Query(graphene.ObjectType):

author = graphene.Field(
TypeAuthor,
author_id=graphene.Argument(type=graphene.Int, required=False),
name_first=graphene.Argument(type=graphene.String, required=False),
name_last=graphene.Argument(type=graphene.String, required=False),
)

books = graphene.List(
of_type=TypeBook,
title=graphene.Argument(type=graphene.String, required=False),
year=graphene.Argument(type=graphene.Int, required=False),
)

stats = graphene.Field(type=TypeStats)

@staticmethod
def resolve_stats(
args: Dict,
info: graphql.execution.base.ResolveInfo,
):
return TypeStats

@staticmethod
def resolve_author(
args: Dict,
info: graphql.execution.base.ResolveInfo,
author_id: Union[int, None] = None,
name_first: Union[str, None] = None,
name_last: Union[str, None] = None,
):

query = TypeAuthor.get_query(info=info)

if author_id:
query = query.filter(Author.author_id == author_id)

if name_first:
query = query.filter(Author.name_first == name_first)

if name_last:
query = query.filter(Author.name_last == name_last)

# Limit query to the requested fields only.
query = apply_requested_fields(info=info, query=query, orm_class=Author)

author = query.first()

return author

@staticmethod
def resolve_books(
args: Dict,
info: graphql.execution.base.ResolveInfo,
title: Union[str, None] = None,
year: Union[int, None] = None,
):
query = TypeBook.get_query(info=info)

if title:
query = query.filter(Book.title == title)

if year:
query = query.filter(Book.year == year)

# Limit query to the requested fields only.
query = apply_requested_fields(info=info, query=query, orm_class=Book)

books = query.all()

return books


class Mutation(graphene.ObjectType):
create_author = MutationAuthorCreate.Field()


schema = graphene.Schema(
query=Query,
mutation=Mutation,
types=[
TypeAuthor,
TypeBook,
TypeAuthorBook,
TypeStats,
TypeCountBooksCoverArtist
]
)
43 changes: 43 additions & 0 deletions examples/falcon_sqlalchemy/demo/schema_mutations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# coding=utf-8

from typing import Dict

import graphql
import graphene
import sqlalchemy.orm

from demo.orm import Author
from demo.schema_types import TypeAuthor


class InputAuthor(graphene.InputObjectType):
name_first = graphene.String(required=True)
name_last = graphene.String(required=True)


class MutationAuthorCreate(graphene.Mutation):

class Arguments:
author = InputAuthor(required=True)

author = graphene.Field(TypeAuthor)

@staticmethod
def mutate(
args: Dict,
info: graphql.execution.base.ResolveInfo,
author=None
):

# Retrieve the session out of the context as the `get_query` method
# automatically selects the model.
session = info.context.get("session") # type: sqlalchemy.orm.Session

obj = Author()
obj.name_first = author.name_first
obj.name_last = author.name_last

session.add(obj)
session.commit()

return MutationAuthorCreate(author=obj)
68 changes: 68 additions & 0 deletions examples/falcon_sqlalchemy/demo/schema_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# coding=utf-8

from typing import List, Dict

import graphql
import graphene
from graphene_sqlalchemy import SQLAlchemyObjectType
import sqlalchemy.orm
from sqlalchemy import func as sqlalchemy_func

from demo.orm import Author
from demo.orm import Book
from demo.orm import AuthorBook


class TypeAuthor(SQLAlchemyObjectType):
class Meta:
model = Author


class TypeBook(SQLAlchemyObjectType):
class Meta:
model = Book


class TypeAuthorBook(SQLAlchemyObjectType):
class Meta:
model = AuthorBook


class TypeCountBooksCoverArtist(graphene.ObjectType):
cover_artist = graphene.String()
count_books = graphene.Int()


class TypeStats(graphene.ObjectType):

count_books_by_cover_artist = graphene.List(
of_type=TypeCountBooksCoverArtist
)

@staticmethod
def resolve_count_books_by_cover_artist(
args: Dict,
info: graphql.execution.base.ResolveInfo,
) -> List[TypeCountBooksCoverArtist]:
# Retrieve the session out of the context as the `get_query` method
# automatically selects the model.
session = info.context.get("session") # type: sqlalchemy.orm.Session

# Define the `COUNT(books.book_id)` function.
func_count_books = sqlalchemy_func.count(Book.book_id)

# Query out the count of books by cover-artist
query = session.query(Book.cover_artist, func_count_books)
query = query.group_by(Book.cover_artist)
results = query.all()

# Wrap the results of the aggregation in `TypeCountBooksCoverArtist`
# objects.
objs = [
TypeCountBooksCoverArtist(
cover_artist=result[0],
count_books=result[1]
) for result in results
]

return objs
168 changes: 168 additions & 0 deletions examples/falcon_sqlalchemy/demo/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# coding=utf-8

from typing import List, Dict, Union, Type

import graphql
from graphql.language.ast import FragmentSpread
from graphql.language.ast import Field
from graphene.utils.str_converters import to_snake_case
import sqlalchemy.orm

from demo.orm_base import OrmBaseMixin


def extract_requested_fields(
info: graphql.execution.base.ResolveInfo,
fields: List[Union[Field, FragmentSpread]],
do_convert_to_snake_case: bool = True,
) -> Dict:
"""Extracts the fields requested in a GraphQL query by processing the AST
and returns a nested dictionary representing the requested fields.
Note:
This function should support arbitrarily nested field structures
including fragments.
Example:
Consider the following query passed to a resolver and running this
function with the `ResolveInfo` object passed to the resolver.
>>> query = "query getAuthor{author(authorId: 1){nameFirst, nameLast}}"
>>> extract_requested_fields(info, info.field_asts, True)
{'author': {'name_first': None, 'name_last': None}}
Args:
info (graphql.execution.base.ResolveInfo): The GraphQL query info passed
to the resolver function.
fields (List[Union[Field, FragmentSpread]]): The list of `Field` or
`FragmentSpread` objects parsed out of the GraphQL query and stored
in the AST.
do_convert_to_snake_case (bool): Whether to convert the fields as they
appear in the GraphQL query (typically in camel-case) back to
snake-case (which is how they typically appear in ORM classes).
Returns:
Dict: The nested dictionary containing all the requested fields.
"""

result = {}
for field in fields:

# Set the `key` as the field name.
key = field.name.value

# Convert the key from camel-case to snake-case (if required).
if do_convert_to_snake_case:
key = to_snake_case(name=key)

# Initialize `val` to `None`. Fields without nested-fields under them
# will have a dictionary value of `None`.
val = None

# If the field is of type `Field` then extract the nested fields under
# the `selection_set` (if defined). These nested fields will be
# extracted recursively and placed in a dictionary under the field
# name in the `result` dictionary.
if isinstance(field, Field):
if (
hasattr(field, "selection_set") and
field.selection_set is not None
):
# Extract field names out of the field selections.
val = extract_requested_fields(
info=info,
fields=field.selection_set.selections,
)
result[key] = val
# If the field is of type `FragmentSpread` then retrieve the fragment
# from `info.fragments` and recursively extract the nested fields but
# as we don't want the name of the fragment appearing in the result
# dictionary (since it does not match anything in the ORM classes) the
# result will simply be result of the extraction.
elif isinstance(field, FragmentSpread):
# Retrieve referened fragment.
fragment = info.fragments[field.name.value]
# Extract field names out of the fragment selections.
val = extract_requested_fields(
info=info,
fields=fragment.selection_set.selections,
)
result = val

return result


def apply_requested_fields(
info: graphql.execution.base.ResolveInfo,
query: sqlalchemy.orm.Query,
orm_class: Type[OrmBaseMixin]
) -> sqlalchemy.orm.Query:
"""Updates the SQLAlchemy Query object by limiting the loaded fields of the
table and its relationship to the ones explicitly requested in the GraphQL
query.
Note:
This function is fairly simplistic in that it assumes that (1) the
SQLAlchemy query only selects a single ORM class/table and that (2)
relationship fields are only one level deep, i.e., that requestd fields
are either table fields or fields of the table relationship, e.g., it
does not support fields of relationship relationships.
Args:
info (graphql.execution.base.ResolveInfo): The GraphQL query info passed
to the resolver function.
query (sqlalchemy.orm.Query): The SQLAlchemy Query object to be updated.
orm_class (Type[OrmBaseMixin]): The ORM class of the selected table.
Returns:
sqlalchemy.orm.Query: The updated SQLAlchemy Query object.
"""

# Extract the fields requested in the GraphQL query.
fields = extract_requested_fields(
info=info,
fields=info.field_asts,
do_convert_to_snake_case=True,
)

# We assume that the top level of the `fields` dictionary only contains a
# single key referring to the GraphQL resource being resolved.
tl_key = list(fields.keys())[0]
# We assume that any keys that have a value of `None` (as opposed to
# dictionaries) are fields of the primary table.
table_fields = [
key for key, val in fields[tl_key].items()
if val is None
]

# We assume that any keys that have a value being a dictionary are
# relationship attributes on the primary table with the keys in the
# dictionary being fields on that relationship. Thus we create a list of
# `[relatioship_name, relationship_fields]` lists to be used in the
# `joinedload` definitions.
relationship_fieldsets = [
[key, val.keys()]
for key, val in fields[tl_key].items()
if isinstance(val, dict)
]

# Assemble a list of `joinedload` definitions on the defined relationship
# attribute name and the requested fields on that relationship.
options_joinedloads = []
for relationship_fieldset in relationship_fieldsets:
relationship = relationship_fieldset[0]
rel_fields = relationship_fieldset[1]
options_joinedloads.append(
sqlalchemy.orm.joinedload(
getattr(orm_class, relationship)
).load_only(*rel_fields)
)

# Update the SQLAlchemy query by limiting the loaded fields on the primary
# table as well as by including the `joinedload` definitions.
query = query.options(
sqlalchemy.orm.load_only(*table_fields),
*options_joinedloads
)

return query
1,263 changes: 1,263 additions & 0 deletions examples/falcon_sqlalchemy/graphiql/graphiql.css

Large diffs are not rendered by default.

121 changes: 121 additions & 0 deletions examples/falcon_sqlalchemy/graphiql/graphiql.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<!--
* Copyright (c) 2015, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the license found in the
* LICENSE file in the root directory of this source tree.
*
-->
<!DOCTYPE html>
<html>
<head>
<style>
body {
height: 100%;
margin: 0;
width: 100%;
overflow: hidden;
}
#graphiql {
height: 100vh;
}
</style>
<link rel="stylesheet" href="./graphiql/graphiql.css" />
<script src="//cdn.jsdelivr.net/fetch/0.9.0/fetch.min.js"></script>
<script>window.fetch || document.write('<script src="./graphiql/vendor/fetch.min.js">\x3C/script>')</script>
<script src="//cdn.jsdelivr.net/react/15.0.1/react.min.js"></script>
<script>window.React || document.write('<script src="./graphiql/vendor/react-15.0.1.min.js">\x3C/script>')</script>
<script src="//cdn.jsdelivr.net/react/15.0.1/react-dom.min.js"></script>
<script>window.ReactDOM || document.write('<script src="graphiql/vendor/react-dom-15.0.1.min.js">\x3C/script>')</script>
<script src="./graphiql/graphiql.min.js"></script>
</head>
<body>
<div id="graphiql">Loading...</div>
<script>
/**
* This GraphiQL example illustrates how to use some of GraphiQL's props
* in order to enable reading and updating the URL parameters, making
* link sharing of queries a little bit easier.
*
* This is only one example of this kind of feature, GraphiQL exposes
* various React params to enable interesting integrations.
*/
// Parse the search string to get url parameters.
var search = window.location.search;
var parameters = {};
search.substr(1).split('&').forEach(function (entry) {
var eq = entry.indexOf('=');
if (eq >= 0) {
parameters[decodeURIComponent(entry.slice(0, eq))] =
decodeURIComponent(entry.slice(eq + 1));
}
});
// if variables was provided, try to format it.
if (parameters.variables) {
try {
parameters.variables =
JSON.stringify(JSON.parse(parameters.variables), null, 2);
} catch (e) {
// Do nothing, we want to display the invalid JSON as a string, rather
// than present an error.
}
}
// When the query and variables string is edited, update the URL bar so
// that it can be easily shared
function onEditQuery(newQuery) {
parameters.query = newQuery;
updateURL();
}
function onEditVariables(newVariables) {
parameters.variables = newVariables;
updateURL();
}
function onEditOperationName(newOperationName) {
parameters.operationName = newOperationName;
updateURL();
}
function updateURL() {
var newSearch = '?' + Object.keys(parameters).filter(function (key) {
return Boolean(parameters[key]);
}).map(function (key) {
return encodeURIComponent(key) + '=' +
encodeURIComponent(parameters[key]);
}).join('&');
history.replaceState(null, null, newSearch);
}
// Defines a GraphQL fetcher using the fetch API.
function graphQLFetcher(graphQLParams) {
return fetch(window.location.origin + '/graphql', {
method: 'post',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(graphQLParams),
credentials: 'include',
}).then(function (response) {
return response.text();
}).then(function (responseBody) {
try {
return JSON.parse(responseBody);
} catch (error) {
return responseBody;
}
});
}
// Render <GraphiQL /> into the body.
ReactDOM.render(
React.createElement(GraphiQL, {
fetcher: graphQLFetcher,
query: parameters.query,
variables: parameters.variables,
operationName: parameters.operationName,
onEditQuery: onEditQuery,
onEditVariables: onEditVariables,
onEditOperationName: onEditOperationName
}),
document.getElementById('graphiql')
);
</script>
</body>
</html>
16 changes: 16 additions & 0 deletions examples/falcon_sqlalchemy/graphiql/graphiql.min.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions examples/falcon_sqlalchemy/graphiql/vendor/fetch.min.js
16 changes: 16 additions & 0 deletions examples/falcon_sqlalchemy/graphiql/vendor/react-15.0.1.min.js

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions examples/falcon_sqlalchemy/graphiql/vendor/react-dom-15.0.1.min.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* ReactDOM v15.1.0
*
* Copyright 2013-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
*/
!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e(require("react"));else if("function"==typeof define&&define.amd)define(["react"],e);else{var f;f="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,f.ReactDOM=e(f.React)}}(function(e){return e.__SECRET_DOM_DO_NOT_USE_OR_YOU_WILL_BE_FIRED});
68 changes: 68 additions & 0 deletions examples/falcon_sqlalchemy/gunicorn_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# coding=utf-8

import gunicorn

# set the 'server' response header
gunicorn.SERVER_SOFTWARE = 'demo-graphql-sqlalchemy-falcon'

# set the socket to bind on (use 0.0.0.0 for bare deploy)
# this can also be set programatically via the -b flag
bind = 'localhost:5432'

# set the maximum number of pending transactions before an error is returned
backlog = 8192

# set workers
workers = 1

# set threads (optimize for cores * 2-4?)
threads = 1

# set worker class ('sync' is good for normal workloads)
worker_class = 'sync'

# set maximum number of simultaneous client connections per worker process
worker_connections = 4096

# set lifetime of worker in requests before mandatory restart (prevent leaks)
max_requests = 40960

# add jitter to max_requests to avoid workers all stopping at the same time
max_requests_jitter = 7040

# set connection timeout for killing a worker (async jobs still communicate)
timeout = 30

# set time to finish services before restart when signal is received
graceful_timeout = 60

# set keepalive HTTP connection wait time for next request (in seconds)
keepalive = 200

# limit size (in bytes) of requests to guard against denial-of-service attacks
limit_request_line = 8192

# limit number of request header fields as an additional safeguard
limit_request_fields = 25

# Load application code before workers are forked (saves RAM & speeds up boot)
preload = True

# enable reload for automatic worker restarts on code changes during development
#reload = False

# enable spew for intense debugging in order to dump all executed code
#spew = True

# enable daemon to detach worked processes from terminal
#daemon = True

# set logging format and level ('debug', 'info', 'warning', 'error', 'critical')
errorlog = '-'
loglevel = 'debug'
accesslog = '-'
access_log_format = '%(t)s %(s)s "%(r)s" %(L)s %(b)s'
access_log_file = None

# set process name
proc_name = 'demo-graphql-sqlalchemy-falcon'
8 changes: 8 additions & 0 deletions examples/falcon_sqlalchemy/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
SQLAlchemy==1.2.7
graphene==2.1
graphene-sqlalchemy==2.0.0
falcon==1.4.1
gunicorn==19.8.1
graphql-core==2.0
pytest==3.6.0
pytest-runner==4.2