Skip to content
Open
Show file tree
Hide file tree
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
19 changes: 6 additions & 13 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,16 @@
"editor.formatOnSave": true,
"python.linting.flake8Enabled": true,
"python.linting.mypyEnabled": true,
"python.linting.mypyArgs": [
"--follow-imports=silent",
"--ignore-missing-imports",
"--show-column-numbers",
"--no-pretty",
"--config-file",
"${workspaceFolder}/mypy.ini"
],
"python.linting.mypyArgs": ["--config-file", "mypy.ini"],
"python.linting.enabled": true,
"python.formatting.provider": "black",
"python.formatting.blackArgs": [
"--config",
"${workspaceFolder}/pyproject.toml"
],
"python.formatting.blackArgs": ["--config", "pyproject.toml"],
"python.sortImports.args": ["--settings=pyproject.toml"],
"python.analysis.typeCheckingMode": "off",
"python.languageServer": "Pylance",
"python.analysis.typeCheckingMode": "basic",
"python.analysis.diagnosticSeverityOverrides": {
"reportGeneralTypeIssues": "none"
},
"python.linting.flake8Args": ["--max-line-length=99"],
"[python]": {
"editor.codeActionsOnSave": {
Expand Down
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ This is a mini version of the [Inch](https://tryinch.com) tech stack. The schema
consistency between the backend and frontend. Another benefit is a great VS Code environment that
highlights any potential errors when communicating with the backend.

For extra sizzle Strawberry subscriptions keep the task list up to date. When a task is created
the id is published to a `tasks` channel in redis pubsub. Browser have a GraphQL subscription and
WebSocket set up to receive new tasks.

![](https://user-images.githubusercontent.com/701/140308942-264f40fa-f6ac-43cf-88f0-b6c4bfdfe105.mp4)

## Getting started
Expand All @@ -30,6 +34,13 @@ Drop and recreate all database tables
poetry run python models.py
```

Run Redis which is the pubsub to send new tasks via subscriptions

```bash
# brew install redis
redis-server
```

Run the Python GraphQL backend on port :8000 - Next.js will reverse proxy `/graphql` to here

```bash
Expand All @@ -52,5 +63,4 @@ npm run dev

Inside `.vscode/settings.json` you'll see how to have nice VS Code mypy errors, import sorting and
code formatting. Pylance does not yet deal well with declarative type hinted SQLAlchemy models.
However there are pretty good SQLA type stubs and a mypy plugin. That's why in the settings you'll
see `python.analysis.typeCheckingMode` switched off and mypy enabled instead.
However there are pretty good SQLA type stubs for mypy.
63 changes: 53 additions & 10 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
from typing import Optional
import asyncio
from typing import AsyncGenerator, Optional

import aioredis
import async_timeout
import strawberry
from sqlalchemy import select
from starlette.applications import Starlette
from strawberry.asgi import GraphQL
from strawberry.subscriptions import GRAPHQL_TRANSPORT_WS_PROTOCOL

import models

Expand Down Expand Up @@ -33,12 +37,12 @@ def marshal(cls, model: models.Task) -> "Task":
)


# @strawberry.type
# class LocationNotFound:
# message: str = "Location with this name does not exist"
@strawberry.type
class LocationNotFound:
message: str = "Location with this name does not exist"


AddTaskResponse = strawberry.union("AddTaskResponse", (Task,))
AddTaskResponse = strawberry.union("AddTaskResponse", (Task, LocationNotFound))


@strawberry.type
Expand All @@ -49,6 +53,10 @@ class LocationExists:
AddLocationResponse = strawberry.union("AddLocationResponse", (Location, LocationExists))


redis = aioredis.Redis.from_url("redis://localhost", decode_responses=True)
psub = redis.pubsub()


@strawberry.type
class Mutation:
@strawberry.mutation
Expand All @@ -58,11 +66,12 @@ async def add_task(self, name: str, location_name: Optional[str]) -> AddTaskResp
if location_name:
sql = select(models.Location).where(models.Location.name == location_name)
db_location = (await s.execute(sql)).scalars().first()
# if db_location is None:
# return LocationNotFound()
if db_location is None:
return LocationNotFound()
db_task = models.Task(name=name, location=db_location)
s.add(db_task)
await s.commit()
await redis.publish("tasks", str(db_task.id))
return Task.marshal(db_task)

@strawberry.mutation
Expand All @@ -83,7 +92,7 @@ class Query:
@strawberry.field
async def tasks(self) -> list[Task]:
async with models.get_session() as s:
sql = select(models.Task).order_by(models.Task.name)
sql = select(models.Task).order_by(models.Task.id.desc())
db_tasks = (await s.execute(sql)).scalars().unique().all()
return [Task.marshal(task) for task in db_tasks]

Expand All @@ -95,7 +104,41 @@ async def locations(self) -> list[Location]:
return [Location.marshal(loc) for loc in db_locations]


schema = strawberry.Schema(query=Query, mutation=Mutation)
graphql_app = GraphQL(schema)
async def reader(channel: aioredis.client.PubSub):
while True:
try:
async with async_timeout.timeout(1):
message = await channel.get_message(ignore_subscribe_messages=True)
if message is not None:
yield message
await asyncio.sleep(0.01)
except asyncio.TimeoutError:
pass


@strawberry.type
class Subscription:
@strawberry.subscription
async def task_added(self) -> AsyncGenerator[Task, None]:
await psub.subscribe("tasks")
try:
async for message in reader(psub):
task_id = message and message.get("data")
if task_id:
task_id = int(task_id)
else:
continue
async with models.get_session() as s:
sql = select(models.Task).where(models.Task.id == task_id)
db_task = (await s.execute(sql)).unique().scalars().one()
yield Task.marshal(db_task)
finally:
await psub.unsubscribe("tasks")
await psub.reset()


schema = strawberry.Schema(query=Query, mutation=Mutation, subscription=Subscription)
graphql_app = GraphQL(schema, subscription_protocols=[GRAPHQL_TRANSPORT_WS_PROTOCOL])
app = Starlette()
app.add_route("/graphql", graphql_app)
app.add_websocket_route("/graphql", graphql_app)
45 changes: 39 additions & 6 deletions graphql.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import gql from 'graphql-tag';
import * as Urql from 'urql';
export type Maybe<T> = T | null;
export type InputMaybe<T> = Maybe<T>;
export type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
export type MakeOptional<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]?: Maybe<T[SubKey]> };
export type MakeMaybe<T, K extends keyof T> = Omit<T, K> & { [SubKey in K]: Maybe<T[SubKey]> };
Expand All @@ -16,7 +17,7 @@ export type Scalars = {

export type AddLocationResponse = Location | LocationExists;

export type AddTaskResponse = Task;
export type AddTaskResponse = LocationNotFound | Task;

export type Location = {
__typename?: 'Location';
Expand All @@ -29,6 +30,11 @@ export type LocationExists = {
message: Scalars['String'];
};

export type LocationNotFound = {
__typename?: 'LocationNotFound';
message: Scalars['String'];
};

export type Mutation = {
__typename?: 'Mutation';
addLocation: AddLocationResponse;
Expand All @@ -42,7 +48,7 @@ export type MutationAddLocationArgs = {


export type MutationAddTaskArgs = {
locationName?: Maybe<Scalars['String']>;
locationName?: InputMaybe<Scalars['String']>;
name: Scalars['String'];
};

Expand All @@ -52,25 +58,30 @@ export type Query = {
tasks: Array<Task>;
};

export type Subscription = {
__typename?: 'Subscription';
taskAdded: Task;
};

export type Task = {
__typename?: 'Task';
id: Scalars['ID'];
location?: Maybe<Location>;
name: Scalars['String'];
};

export type TaskFieldsFragment = { __typename?: 'Task', id: string, name: string, location?: { __typename?: 'Location', id: string, name: string } | null | undefined };

export type TasksQueryVariables = Exact<{ [key: string]: never; }>;


export type TasksQuery = { __typename?: 'Query', tasks: Array<{ __typename?: 'Task', id: string, name: string, location?: { __typename?: 'Location', name: string } | null | undefined }> };
export type TasksQuery = { __typename?: 'Query', tasks: Array<{ __typename?: 'Task', id: string, name: string, location?: { __typename?: 'Location', id: string, name: string } | null | undefined }> };

export type LocationsQueryVariables = Exact<{ [key: string]: never; }>;


export type LocationsQuery = { __typename?: 'Query', locations: Array<{ __typename?: 'Location', id: string, name: string }> };

export type TaskFieldsFragment = { __typename?: 'Task', id: string, name: string, location?: { __typename?: 'Location', name: string } | null | undefined };

export type LocationFieldsFragment = { __typename?: 'Location', id: string, name: string };

export type AddTaskMutationVariables = Exact<{
Expand All @@ -79,7 +90,7 @@ export type AddTaskMutationVariables = Exact<{
}>;


export type AddTaskMutation = { __typename?: 'Mutation', addTask: { __typename: 'Task', id: string, name: string, location?: { __typename?: 'Location', name: string } | null | undefined } };
export type AddTaskMutation = { __typename?: 'Mutation', addTask: { __typename: 'LocationNotFound', message: string } | { __typename: 'Task', id: string, name: string, location?: { __typename?: 'Location', id: string, name: string } | null | undefined } };

export type AddLocationMutationVariables = Exact<{
name: Scalars['String'];
Expand All @@ -88,11 +99,17 @@ export type AddLocationMutationVariables = Exact<{

export type AddLocationMutation = { __typename?: 'Mutation', addLocation: { __typename: 'Location', id: string, name: string } | { __typename: 'LocationExists', message: string } };

export type TaskAddedSubscriptionVariables = Exact<{ [key: string]: never; }>;


export type TaskAddedSubscription = { __typename?: 'Subscription', taskAdded: { __typename: 'Task', id: string, name: string, location?: { __typename?: 'Location', id: string, name: string } | null | undefined } };

export const TaskFieldsFragmentDoc = gql`
fragment TaskFields on Task {
id
name
location {
id
name
}
}
Expand Down Expand Up @@ -129,6 +146,10 @@ export const AddTaskDocument = gql`
mutation AddTask($name: String!, $locationName: String!) {
addTask(name: $name, locationName: $locationName) {
__typename
... on LocationNotFound {
__typename
message
}
... on Task {
__typename
...TaskFields
Expand Down Expand Up @@ -158,4 +179,16 @@ export const AddLocationDocument = gql`

export function useAddLocationMutation() {
return Urql.useMutation<AddLocationMutation, AddLocationMutationVariables>(AddLocationDocument);
};
export const TaskAddedDocument = gql`
subscription TaskAdded {
taskAdded {
__typename
...TaskFields
}
}
${TaskFieldsFragmentDoc}`;

export function useTaskAddedSubscription<TData = TaskAddedSubscription>(options: Omit<Urql.UseSubscriptionArgs<TaskAddedSubscriptionVariables>, 'query'> = {}, handler?: Urql.SubscriptionHandler<TaskAddedSubscription, TData>) {
return Urql.useSubscription<TaskAddedSubscription, TData, TaskAddedSubscriptionVariables>({ query: TaskAddedDocument, ...options }, handler);
};
5 changes: 5 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
/** @type {import('next').NextConfig} */
module.exports = {
reactStrictMode: true,
experimental: {
// flip `esmExternals` off until @shopify/react-form rebundles after Rollup fix
// https://github.com/rollup/rollup/pull/4270
esmExternals: false,
},
async rewrites() {
return {
beforeFiles: [
Expand Down
32 changes: 20 additions & 12 deletions operation.graphql
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
fragment TaskFields on Task {
id
name
location {
id
name
}
}

query Tasks {
tasks {
...TaskFields
Expand All @@ -10,14 +19,6 @@ query Locations {
}
}

fragment TaskFields on Task {
id
name
location {
name
}
}

fragment LocationFields on Location {
id
name
Expand All @@ -26,10 +27,10 @@ fragment LocationFields on Location {
mutation AddTask($name: String!, $locationName: String!) {
addTask(name: $name, locationName: $locationName) {
__typename
# ... on LocationNotFound {
# __typename
# message
# }
... on LocationNotFound {
__typename
message
}
... on Task {
__typename
...TaskFields
Expand All @@ -50,3 +51,10 @@ mutation AddLocation($name: String!) {
}
}
}

subscription TaskAdded {
taskAdded {
__typename
...TaskFields
}
}
Loading