LGTM Devlog 21: Deploying Pub/Sub-triggered Python Google Cloud Functions

LGTM (40 Part Series)

1 LGTM Devlog 0: Teaching Git through playing a game
2 LGTM Devlog 1: New Logo, and jumping the gun by buying a domain
36 more parts…
3 LGTM Devlog 2: Design Ideas for the game I
4 LGTM Devlog 3: Design Ideas for the game 2
5 LGTM Devlog 4: Technical Decisions – GitHub API and Python
6 LGTM Devlog 5: Technical Decisions – Serverless Architecture
7 LGTM Devlog 6: Sprint 1 plans
8 LGTM Devlog 7: Creating a Firebase project
9 LGTM Devlog 8: Starting a git repo for an open source project from scratch
10 LGTM Devlog 9: Python Google Cloud Functions with Unit Tests and Linting
11 LGTM Devlog 10: Capturing the GitHub webhook for fork requests
12 LGTM Devlog 11: Writing the Serverless Function for receiving GitHub webhooks with Pydantic validation
13 LGTM Devlog 12: CI/CD with GitHub Actions to run Unit Tests and deploy Firebase Functions
14 LGTM Devlog 13: GitHub Branch Protection and Security
15 LGTM Devlog 14: Sprint 1 Retrospective
16 LGTM Devlog 15: Sprint 2 plans
17 LGTM Devlog 16: A serverless data base access rule conundrum
18 LGTM Devlog 17: Website and GitHub OAuth
19 LGTM Devlog 18: Python Serverless functions using GitHub API to validate users
20 LGTM Devlog 19: Game data/quest storage
21 LGTM Devlog 20: Python Abstract Base Class-based data/quest storage
22 LGTM Devlog 21: Deploying Pub/Sub-triggered Python Google Cloud Functions
23 LGTM Devlog 22: Modularization
24 LGTM Devlog 23: Sprint 2 Retrospective
25 LGTM Devlog 24: Sprint 3 plans
26 LGTM Devlog 25: Some cleanup
27 LGTM Devlog 26: Python Graphlib DAGs for Quest Stages
28 LGTM Devlog 27: Branching quests
29 LGTM Devlog 28: Game event loop using Google Cloud Scheduler and PubSub
30 LGTM Devlog 29: ORM for Firestore and __init__subclass__ dunders and metaclasses
31 LGTM Devlog 30: Sprint 3 Retrospective
32 LGTM Devlog 31: Sprint 4 Plan
33 LGTM Devlog 32: Secrets Management to avoid storing API keys in services
34 LGTM Devlog 33: Using PyGithub to post GitHub issues and comments
35 LGTM Devlog 34: Characters Posting on GitHub Issues
36 LGTM Devlog 35: Responding to the player’s answers on GitHub Issues Comments
37 LGTM Devlog 36: Character profiles! On GitHub
38 LGTM Devlog 37: Sprint 4 Retrospective
39 LGTM Devlog 38: Sprint 5 Plan
40 LGTM Devlog 39: Planning the story

With the quest storage structure adequately defined, it’s now time to hook up the game creation endpoint, which is part of a trio of cloud functions involved in handling new users:

  1. GitHub webhook listener – listens for webhook activity on GitHub. This function is specific to GitHub. And triggers a new game if it receives one
  2. Auth flow – a function triggered by the frontend when a user logs in/authenticates, and should attach any games to their account if not already
  3. Create new game – a function that will create new game data and kick off the first quest. This is triggered by the webhook listener

Since we need a function to trigger another function internally, we can use Google Cloud’s pub/sub triggers, which are internal. Though we could also use authenticated HTTP functions, we can switch this later if pub/sub turns out to be the wrong choice. The code matching this post can be found at commit c9074d0

Payload validation

As usual, I will make use of pydantic to perform payload validation, with a model defined, but with a twist: pubsub data comes over base64-encoded, so I’ve sub-classed pydantic’s BaseModel to include a couple of class methods that deal with base64 data, and even specifically consuming the event structure that comes to us from the pub/sub trigger runtime

<span>from</span> <span>typing</span> <span>import</span> <span>Optional</span>
<span>from</span> <span>base64</span> <span>import</span> <span>b64decode</span>
<span>from</span> <span>pydantic</span> <span>import</span> <span>BaseModel</span><span>,</span> <span>Field</span> <span># pylint: disable=no-name-in-module </span>
<span># pylint: disable=too-few-public-methods,missing-class-docstring </span><span>class</span> <span>BaseModelWithPubSub</span><span>(</span><span>BaseModel</span><span>):</span>
<span>""" Extra decode functions for pubsub data """</span>
<span>@</span><span>classmethod</span>
<span>def</span> <span>from_base64</span><span>(</span><span>cls</span><span>,</span> <span>data</span><span>:</span> <span>bytes</span><span>):</span>
<span>return</span> <span>cls</span><span>.</span><span>parse_raw</span><span>(</span><span>b64decode</span><span>(</span><span>data</span><span>).</span><span>decode</span><span>(</span><span>"utf-8"</span><span>))</span>
<span>@</span><span>classmethod</span>
<span>def</span> <span>from_event</span><span>(</span><span>cls</span><span>,</span> <span>event</span><span>:</span> <span>dict</span><span>):</span>
<span>return</span> <span>cls</span><span>.</span><span>from_base64</span><span>(</span><span>event</span><span>[</span><span>"data"</span><span>])</span>
<span>class</span> <span>NewGameData</span><span>(</span><span>BaseModelWithPubSub</span><span>):</span>
<span>""" Create a new game """</span>
<span>source</span><span>:</span> <span>str</span> <span>=</span> <span>Field</span><span>(...,</span> <span>title</span><span>=</span><span>"User source, e.g. 'github'"</span><span>)</span>
<span>userId</span><span>:</span> <span>str</span> <span>=</span> <span>Field</span><span>(...,</span> <span>title</span><span>=</span><span>"User ID"</span><span>)</span>
<span>userUid</span><span>:</span> <span>Optional</span><span>[</span><span>str</span><span>]</span> <span>=</span> <span>Field</span><span>(</span><span>None</span><span>,</span> <span>title</span><span>=</span><span>"Auth UID if known"</span><span>)</span>
<span>forkUrl</span><span>:</span> <span>str</span> <span>=</span> <span>Field</span><span>(...,</span> <span>title</span><span>=</span><span>"URL of the LGTM fork"</span><span>)</span>
<span>from</span> <span>typing</span> <span>import</span> <span>Optional</span>
<span>from</span> <span>base64</span> <span>import</span> <span>b64decode</span>
<span>from</span> <span>pydantic</span> <span>import</span> <span>BaseModel</span><span>,</span> <span>Field</span>  <span># pylint: disable=no-name-in-module </span>
<span># pylint: disable=too-few-public-methods,missing-class-docstring </span><span>class</span> <span>BaseModelWithPubSub</span><span>(</span><span>BaseModel</span><span>):</span>
    <span>""" Extra decode functions for pubsub data """</span>

    <span>@</span><span>classmethod</span>
    <span>def</span> <span>from_base64</span><span>(</span><span>cls</span><span>,</span> <span>data</span><span>:</span> <span>bytes</span><span>):</span>
        <span>return</span> <span>cls</span><span>.</span><span>parse_raw</span><span>(</span><span>b64decode</span><span>(</span><span>data</span><span>).</span><span>decode</span><span>(</span><span>"utf-8"</span><span>))</span>

    <span>@</span><span>classmethod</span>
    <span>def</span> <span>from_event</span><span>(</span><span>cls</span><span>,</span> <span>event</span><span>:</span> <span>dict</span><span>):</span>
        <span>return</span> <span>cls</span><span>.</span><span>from_base64</span><span>(</span><span>event</span><span>[</span><span>"data"</span><span>])</span>


<span>class</span> <span>NewGameData</span><span>(</span><span>BaseModelWithPubSub</span><span>):</span>
    <span>""" Create a new game """</span>

    <span>source</span><span>:</span> <span>str</span> <span>=</span> <span>Field</span><span>(...,</span> <span>title</span><span>=</span><span>"User source, e.g. 'github'"</span><span>)</span>
    <span>userId</span><span>:</span> <span>str</span> <span>=</span> <span>Field</span><span>(...,</span> <span>title</span><span>=</span><span>"User ID"</span><span>)</span>
    <span>userUid</span><span>:</span> <span>Optional</span><span>[</span><span>str</span><span>]</span> <span>=</span> <span>Field</span><span>(</span><span>None</span><span>,</span> <span>title</span><span>=</span><span>"Auth UID if known"</span><span>)</span>
    <span>forkUrl</span><span>:</span> <span>str</span> <span>=</span> <span>Field</span><span>(...,</span> <span>title</span><span>=</span><span>"URL of the LGTM fork"</span><span>)</span>
from typing import Optional from base64 import b64decode from pydantic import BaseModel, Field # pylint: disable=no-name-in-module # pylint: disable=too-few-public-methods,missing-class-docstring class BaseModelWithPubSub(BaseModel): """ Extra decode functions for pubsub data """ @classmethod def from_base64(cls, data: bytes): return cls.parse_raw(b64decode(data).decode("utf-8")) @classmethod def from_event(cls, event: dict): return cls.from_base64(event["data"]) class NewGameData(BaseModelWithPubSub): """ Create a new game """ source: str = Field(..., title="User source, e.g. 'github'") userId: str = Field(..., title="User ID") userUid: Optional[str] = Field(None, title="Auth UID if known") forkUrl: str = Field(..., title="URL of the LGTM fork")

Enter fullscreen mode Exit fullscreen mode

Endpoint

So that leaves the endpoint looking like this, it does the following:

  1. Decode the event payload
  2. Create a game structure in Firestore. Right now it only has the user-identification data, we’ll add to it as we build game logic
  3. Create the start quest as well, using the fancy new Quest object we created to generate the data for a new game, OR having it load any existing one and save a new version. The anticipation here is if we have to deal with any quest version updates, we can let the quest object deal with it if we need to.
<span>def</span> <span>create_new_game</span><span>(</span><span>event</span><span>:</span> <span>dict</span><span>,</span> <span>context</span><span>:</span> <span>Context</span><span>):</span>
<span>""" Create a new game """</span>
<span>logger</span><span>.</span><span>info</span><span>(</span><span>"Got create new game request"</span><span>,</span> <span>payload</span><span>=</span><span>event</span><span>)</span>
<span># decode event </span> <span>try</span><span>:</span>
<span>new_game_data</span> <span>=</span> <span>NewGameData</span><span>.</span><span>from_event</span><span>(</span><span>event</span><span>)</span>
<span>except</span> <span>ValidationError</span> <span>as</span> <span>err</span><span>:</span>
<span>logger</span><span>.</span><span>error</span><span>(</span><span>"Validation error"</span><span>,</span> <span>err</span><span>=</span><span>err</span><span>)</span>
<span>raise</span> <span>err</span>
<span>logger</span><span>.</span><span>info</span><span>(</span><span>"Resolved data"</span><span>,</span> <span>new_game_data</span><span>=</span><span>new_game_data</span><span>)</span>
<span># create game if doesn't exist </span> <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>new_game_data</span><span>.</span><span>source</span><span>,</span> <span>new_game_data</span><span>.</span><span>userId</span><span>)</span>
<span>game_ref</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>)</span>
<span>game</span> <span>=</span> <span>game_ref</span><span>.</span><span>get</span><span>()</span>
<span>if</span> <span>game</span><span>.</span><span>exists</span><span>:</span>
<span>logger</span><span>.</span><span>info</span><span>(</span><span>"Game already exists"</span><span>,</span> <span>game_id</span><span>=</span><span>game_id</span><span>)</span>
<span>game_ref</span><span>.</span><span>set</span><span>(</span>
<span>{</span>
<span>**</span><span>new_game_data</span><span>.</span><span>dict</span><span>(),</span>
<span>},</span>
<span>merge</span><span>=</span><span>True</span><span>,</span>
<span>)</span>
<span>else</span><span>:</span>
<span>logger</span><span>.</span><span>info</span><span>(</span><span>"Creating new game"</span><span>,</span> <span>game_id</span><span>=</span><span>game_id</span><span>)</span>
<span>game_ref</span><span>.</span><span>set</span><span>(</span>
<span>{</span>
<span>**</span><span>new_game_data</span><span>.</span><span>dict</span><span>(),</span>
<span>"joined"</span><span>:</span> <span>firestore</span><span>.</span><span>SERVER_TIMESTAMP</span><span>,</span>
<span>}</span>
<span>)</span>
<span># create starting quest if not exist </span> <span>FirstQuest</span> <span>=</span> <span>get_quest_by_name</span><span>(</span><span>FIRST_QUEST_NAME</span><span>)</span>
<span>quest_obj</span> <span>=</span> <span>FirstQuest</span><span>()</span>
<span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span>
<span>quest_ref</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>)</span>
<span>quest</span> <span>=</span> <span>quest_ref</span><span>.</span><span>get</span><span>()</span>
<span>if</span> <span>quest</span><span>.</span><span>exists</span><span>:</span>
<span>logger</span><span>.</span><span>info</span><span>(</span><span>"Quest already exists, updating"</span><span>,</span> <span>quest_id</span><span>=</span><span>quest_id</span><span>)</span>
<span>try</span><span>:</span>
<span>quest_obj</span><span>.</span><span>load</span><span>(</span><span>quest</span><span>.</span><span>to_dict</span><span>())</span>
<span>except</span> <span>QuestLoadError</span> <span>as</span> <span>err</span><span>:</span>
<span>logger</span><span>.</span><span>error</span><span>(</span><span>"Could not load"</span><span>,</span> <span>err</span><span>=</span><span>err</span><span>)</span>
<span>raise</span> <span>err</span>
<span>quest_ref</span><span>.</span><span>set</span><span>(</span><span>quest_obj</span><span>.</span><span>get_save_data</span><span>())</span>
<span>def</span> <span>create_new_game</span><span>(</span><span>event</span><span>:</span> <span>dict</span><span>,</span> <span>context</span><span>:</span> <span>Context</span><span>):</span>
    <span>""" Create a new game """</span>
    <span>logger</span><span>.</span><span>info</span><span>(</span><span>"Got create new game request"</span><span>,</span> <span>payload</span><span>=</span><span>event</span><span>)</span>

    <span># decode event </span>    <span>try</span><span>:</span>
        <span>new_game_data</span> <span>=</span> <span>NewGameData</span><span>.</span><span>from_event</span><span>(</span><span>event</span><span>)</span>
    <span>except</span> <span>ValidationError</span> <span>as</span> <span>err</span><span>:</span>
        <span>logger</span><span>.</span><span>error</span><span>(</span><span>"Validation error"</span><span>,</span> <span>err</span><span>=</span><span>err</span><span>)</span>
        <span>raise</span> <span>err</span>

    <span>logger</span><span>.</span><span>info</span><span>(</span><span>"Resolved data"</span><span>,</span> <span>new_game_data</span><span>=</span><span>new_game_data</span><span>)</span>

    <span># create game if doesn't exist </span>    <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>new_game_data</span><span>.</span><span>source</span><span>,</span> <span>new_game_data</span><span>.</span><span>userId</span><span>)</span>
    <span>game_ref</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>)</span>
    <span>game</span> <span>=</span> <span>game_ref</span><span>.</span><span>get</span><span>()</span>
    <span>if</span> <span>game</span><span>.</span><span>exists</span><span>:</span>
        <span>logger</span><span>.</span><span>info</span><span>(</span><span>"Game already exists"</span><span>,</span> <span>game_id</span><span>=</span><span>game_id</span><span>)</span>
        <span>game_ref</span><span>.</span><span>set</span><span>(</span>
            <span>{</span>
                <span>**</span><span>new_game_data</span><span>.</span><span>dict</span><span>(),</span>
            <span>},</span>
            <span>merge</span><span>=</span><span>True</span><span>,</span>
        <span>)</span>
    <span>else</span><span>:</span>
        <span>logger</span><span>.</span><span>info</span><span>(</span><span>"Creating new game"</span><span>,</span> <span>game_id</span><span>=</span><span>game_id</span><span>)</span>
        <span>game_ref</span><span>.</span><span>set</span><span>(</span>
            <span>{</span>
                <span>**</span><span>new_game_data</span><span>.</span><span>dict</span><span>(),</span>
                <span>"joined"</span><span>:</span> <span>firestore</span><span>.</span><span>SERVER_TIMESTAMP</span><span>,</span>
            <span>}</span>
        <span>)</span>

    <span># create starting quest if not exist </span>    <span>FirstQuest</span> <span>=</span> <span>get_quest_by_name</span><span>(</span><span>FIRST_QUEST_NAME</span><span>)</span>
    <span>quest_obj</span> <span>=</span> <span>FirstQuest</span><span>()</span>

    <span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span>
    <span>quest_ref</span> <span>=</span> <span>db</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>)</span>

    <span>quest</span> <span>=</span> <span>quest_ref</span><span>.</span><span>get</span><span>()</span>
    <span>if</span> <span>quest</span><span>.</span><span>exists</span><span>:</span>
        <span>logger</span><span>.</span><span>info</span><span>(</span><span>"Quest already exists, updating"</span><span>,</span> <span>quest_id</span><span>=</span><span>quest_id</span><span>)</span>

        <span>try</span><span>:</span>
            <span>quest_obj</span><span>.</span><span>load</span><span>(</span><span>quest</span><span>.</span><span>to_dict</span><span>())</span>
        <span>except</span> <span>QuestLoadError</span> <span>as</span> <span>err</span><span>:</span>
            <span>logger</span><span>.</span><span>error</span><span>(</span><span>"Could not load"</span><span>,</span> <span>err</span><span>=</span><span>err</span><span>)</span>
            <span>raise</span> <span>err</span>

    <span>quest_ref</span><span>.</span><span>set</span><span>(</span><span>quest_obj</span><span>.</span><span>get_save_data</span><span>())</span>
def create_new_game(event: dict, context: Context): """ Create a new game """ logger.info("Got create new game request", payload=event) # decode event try: new_game_data = NewGameData.from_event(event) except ValidationError as err: logger.error("Validation error", err=err) raise err logger.info("Resolved data", new_game_data=new_game_data) # create game if doesn't exist game_id = create_game_id(new_game_data.source, new_game_data.userId) game_ref = db.collection("game").document(game_id) game = game_ref.get() if game.exists: logger.info("Game already exists", game_id=game_id) game_ref.set( { **new_game_data.dict(), }, merge=True, ) else: logger.info("Creating new game", game_id=game_id) game_ref.set( { **new_game_data.dict(), "joined": firestore.SERVER_TIMESTAMP, } ) # create starting quest if not exist FirstQuest = get_quest_by_name(FIRST_QUEST_NAME) quest_obj = FirstQuest() quest_id = create_quest_id(game_id, FIRST_QUEST_NAME) quest_ref = db.collection("quest").document(quest_id) quest = quest_ref.get() if quest.exists: logger.info("Quest already exists, updating", quest_id=quest_id) try: quest_obj.load(quest.to_dict()) except QuestLoadError as err: logger.error("Could not load", err=err) raise err quest_ref.set(quest_obj.get_save_data())

Enter fullscreen mode Exit fullscreen mode

Tests

The text fixture for triggering a pub/sub payload as a little more complex, as it needs to contain a json structure. Interestingly, the functions-framework appears to still use an the test-client with a Post() request, though I don’t know exactly what’s going on under the hood – it’s not well documented and I’m figuring things out from their own unit tests.

<span>@</span><span>pytest</span><span>.</span><span>fixture</span><span>(</span><span>scope</span><span>=</span><span>"package"</span><span>)</span>
<span>def</span> <span>new_game_post</span><span>():</span>
<span>""" Test client for newgame"""</span>
<span>client</span> <span>=</span> <span>create_app</span><span>(</span><span>"create_new_game"</span><span>,</span> <span>FUNCTION_SOURCE</span><span>,</span> <span>"event"</span><span>).</span><span>test_client</span><span>()</span>
<span>return</span> <span>lambda</span> <span>data</span><span>:</span> <span>client</span><span>.</span><span>post</span><span>(</span>
<span>"/"</span><span>,</span>
<span>json</span><span>=</span><span>{</span>
<span>"context"</span><span>:</span> <span>{</span>
<span>"eventId"</span><span>:</span> <span>"some-eventId"</span><span>,</span>
<span>"timestamp"</span><span>:</span> <span>"some-timestamp"</span><span>,</span>
<span>"eventType"</span><span>:</span> <span>"some-eventType"</span><span>,</span>
<span>"resource"</span><span>:</span> <span>"some-resource"</span><span>,</span>
<span>},</span>
<span>"data"</span><span>:</span> <span>{</span><span>"data"</span><span>:</span> <span>b64encode</span><span>(</span><span>json</span><span>.</span><span>dumps</span><span>(</span><span>data</span><span>).</span><span>encode</span><span>()).</span><span>decode</span><span>()},</span>
<span>},</span>
<span>)</span>
<span>@</span><span>pytest</span><span>.</span><span>fixture</span><span>(</span><span>scope</span><span>=</span><span>"package"</span><span>)</span>
<span>def</span> <span>new_game_post</span><span>():</span>
    <span>""" Test client for newgame"""</span>
    <span>client</span> <span>=</span> <span>create_app</span><span>(</span><span>"create_new_game"</span><span>,</span> <span>FUNCTION_SOURCE</span><span>,</span> <span>"event"</span><span>).</span><span>test_client</span><span>()</span> 

    <span>return</span> <span>lambda</span> <span>data</span><span>:</span> <span>client</span><span>.</span><span>post</span><span>(</span>
        <span>"/"</span><span>,</span>
        <span>json</span><span>=</span><span>{</span>
            <span>"context"</span><span>:</span> <span>{</span>
                <span>"eventId"</span><span>:</span> <span>"some-eventId"</span><span>,</span>
                <span>"timestamp"</span><span>:</span> <span>"some-timestamp"</span><span>,</span>
                <span>"eventType"</span><span>:</span> <span>"some-eventType"</span><span>,</span>
                <span>"resource"</span><span>:</span> <span>"some-resource"</span><span>,</span>
            <span>},</span>
            <span>"data"</span><span>:</span> <span>{</span><span>"data"</span><span>:</span> <span>b64encode</span><span>(</span><span>json</span><span>.</span><span>dumps</span><span>(</span><span>data</span><span>).</span><span>encode</span><span>()).</span><span>decode</span><span>()},</span>
        <span>},</span>
    <span>)</span>
@pytest.fixture(scope="package") def new_game_post(): """ Test client for newgame""" client = create_app("create_new_game", FUNCTION_SOURCE, "event").test_client() return lambda data: client.post( "/", json={ "context": { "eventId": "some-eventId", "timestamp": "some-timestamp", "eventType": "some-eventType", "resource": "some-resource", }, "data": {"data": b64encode(json.dumps(data).encode()).decode()}, }, )

Enter fullscreen mode Exit fullscreen mode

The test (I guess an integration test) looks like this:

<span>@</span><span>pytest</span><span>.</span><span>fixture</span>
<span>def</span> <span>new_game_data</span><span>(</span><span>firestore_client</span><span>):</span>
<span>uid</span> <span>=</span> <span>"test_user_"</span> <span>+</span> <span>""</span><span>.</span><span>join</span><span>(</span>
<span>[</span><span>random</span><span>.</span><span>choice</span><span>(</span><span>string</span><span>.</span><span>ascii_letters</span><span>)</span> <span>for</span> <span>_</span> <span>in</span> <span>range</span><span>(</span><span>6</span><span>)]</span>
<span>)</span>
<span># create user data </span> <span>yield</span> <span>NewGameData</span><span>(</span>
<span>source</span><span>=</span><span>SOURCE</span><span>,</span>
<span>userId</span><span>=</span><span>uid</span><span>,</span>
<span>userUid</span><span>=</span><span>uid</span><span>,</span>
<span>forkUrl</span><span>=</span><span>"test_url"</span><span>,</span>
<span>).</span><span>dict</span><span>()</span>
<span># cleanup </span> <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>SOURCE</span><span>,</span> <span>uid</span><span>)</span>
<span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span>
<span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>).</span><span>delete</span><span>()</span>
<span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>delete</span><span>()</span>
<span>def</span> <span>test_fail_quest</span><span>(</span><span>firestore_client</span><span>,</span> <span>new_game_post</span><span>,</span> <span>new_game_data</span><span>):</span>
<span>""" Test situation where quest creation fails """</span>
<span># check game and quest does not exist </span> <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>SOURCE</span><span>,</span> <span>new_game_data</span><span>[</span><span>"userId"</span><span>])</span>
<span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span>
<span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>set</span><span>({</span><span>"_version"</span><span>:</span> <span>"999.9.9"</span><span>})</span>
<span># create! </span> <span>res</span> <span>=</span> <span>new_game_post</span><span>(</span><span>new_game_data</span><span>)</span>
<span>assert</span> <span>res</span><span>.</span><span>status_code</span> <span>==</span> <span>500</span>
<span>def</span> <span>test_game_creation</span><span>(</span><span>firestore_client</span><span>,</span> <span>new_game_post</span><span>,</span> <span>new_game_data</span><span>):</span>
<span>""" Test successful game creation flow """</span>
<span># check game and quest does not exist </span> <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>SOURCE</span><span>,</span> <span>new_game_data</span><span>[</span><span>"userId"</span><span>])</span>
<span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span>
<span>game</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>).</span><span>get</span><span>()</span>
<span>assert</span> <span>not</span> <span>game</span><span>.</span><span>exists</span>
<span>quest</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>get</span><span>()</span>
<span>assert</span> <span>not</span> <span>quest</span><span>.</span><span>exists</span>
<span># create! </span> <span>res</span> <span>=</span> <span>new_game_post</span><span>(</span><span>new_game_data</span><span>)</span>
<span>assert</span> <span>res</span><span>.</span><span>status_code</span> <span>==</span> <span>200</span>
<span># check if game actually created, and that it contains data </span> <span>game</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>).</span><span>get</span><span>()</span>
<span>assert</span> <span>game</span><span>.</span><span>exists</span>
<span>game_dict</span> <span>=</span> <span>game</span><span>.</span><span>to_dict</span><span>()</span>
<span>assert</span> <span>game_dict</span><span>.</span><span>items</span><span>()</span> <span>>=</span> <span>new_game_data</span><span>.</span><span>items</span><span>()</span>
<span># check if quest was created and that it contains data </span> <span>quest</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>get</span><span>()</span>
<span>assert</span> <span>quest</span><span>.</span><span>exists</span>
<span># try create again, sohuld still work, but be idempotent </span> <span>res</span> <span>=</span> <span>new_game_post</span><span>(</span><span>new_game_data</span><span>)</span>
<span>assert</span> <span>res</span><span>.</span><span>status_code</span> <span>==</span> <span>200</span>
<span>@</span><span>pytest</span><span>.</span><span>fixture</span>
<span>def</span> <span>new_game_data</span><span>(</span><span>firestore_client</span><span>):</span>
    <span>uid</span> <span>=</span> <span>"test_user_"</span> <span>+</span> <span>""</span><span>.</span><span>join</span><span>(</span>
        <span>[</span><span>random</span><span>.</span><span>choice</span><span>(</span><span>string</span><span>.</span><span>ascii_letters</span><span>)</span> <span>for</span> <span>_</span> <span>in</span> <span>range</span><span>(</span><span>6</span><span>)]</span>
    <span>)</span>

    <span># create user data </span>    <span>yield</span> <span>NewGameData</span><span>(</span>
        <span>source</span><span>=</span><span>SOURCE</span><span>,</span>
        <span>userId</span><span>=</span><span>uid</span><span>,</span>
        <span>userUid</span><span>=</span><span>uid</span><span>,</span>
        <span>forkUrl</span><span>=</span><span>"test_url"</span><span>,</span>
    <span>).</span><span>dict</span><span>()</span>

    <span># cleanup </span>    <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>SOURCE</span><span>,</span> <span>uid</span><span>)</span>
    <span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span>
    <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>).</span><span>delete</span><span>()</span>
    <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>delete</span><span>()</span>


<span>def</span> <span>test_fail_quest</span><span>(</span><span>firestore_client</span><span>,</span> <span>new_game_post</span><span>,</span> <span>new_game_data</span><span>):</span>
    <span>""" Test situation where quest creation fails """</span>

    <span># check game and quest does not exist </span>    <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>SOURCE</span><span>,</span> <span>new_game_data</span><span>[</span><span>"userId"</span><span>])</span>
    <span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span>

    <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>set</span><span>({</span><span>"_version"</span><span>:</span> <span>"999.9.9"</span><span>})</span>

    <span># create! </span>    <span>res</span> <span>=</span> <span>new_game_post</span><span>(</span><span>new_game_data</span><span>)</span>
    <span>assert</span> <span>res</span><span>.</span><span>status_code</span> <span>==</span> <span>500</span>


<span>def</span> <span>test_game_creation</span><span>(</span><span>firestore_client</span><span>,</span> <span>new_game_post</span><span>,</span> <span>new_game_data</span><span>):</span>
    <span>""" Test successful game creation flow """</span>

    <span># check game and quest does not exist </span>    <span>game_id</span> <span>=</span> <span>create_game_id</span><span>(</span><span>SOURCE</span><span>,</span> <span>new_game_data</span><span>[</span><span>"userId"</span><span>])</span>
    <span>quest_id</span> <span>=</span> <span>create_quest_id</span><span>(</span><span>game_id</span><span>,</span> <span>FIRST_QUEST_NAME</span><span>)</span>

    <span>game</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>).</span><span>get</span><span>()</span>
    <span>assert</span> <span>not</span> <span>game</span><span>.</span><span>exists</span>
    <span>quest</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>get</span><span>()</span>
    <span>assert</span> <span>not</span> <span>quest</span><span>.</span><span>exists</span>

    <span># create! </span>    <span>res</span> <span>=</span> <span>new_game_post</span><span>(</span><span>new_game_data</span><span>)</span>
    <span>assert</span> <span>res</span><span>.</span><span>status_code</span> <span>==</span> <span>200</span>

    <span># check if game actually created, and that it contains data </span>    <span>game</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"game"</span><span>).</span><span>document</span><span>(</span><span>game_id</span><span>).</span><span>get</span><span>()</span>
    <span>assert</span> <span>game</span><span>.</span><span>exists</span>
    <span>game_dict</span> <span>=</span> <span>game</span><span>.</span><span>to_dict</span><span>()</span>
    <span>assert</span> <span>game_dict</span><span>.</span><span>items</span><span>()</span> <span>>=</span> <span>new_game_data</span><span>.</span><span>items</span><span>()</span>

    <span># check if quest was created and that it contains data </span>    <span>quest</span> <span>=</span> <span>firestore_client</span><span>.</span><span>collection</span><span>(</span><span>"quest"</span><span>).</span><span>document</span><span>(</span><span>quest_id</span><span>).</span><span>get</span><span>()</span>
    <span>assert</span> <span>quest</span><span>.</span><span>exists</span>

    <span># try create again, sohuld still work, but be idempotent </span>    <span>res</span> <span>=</span> <span>new_game_post</span><span>(</span><span>new_game_data</span><span>)</span>
    <span>assert</span> <span>res</span><span>.</span><span>status_code</span> <span>==</span> <span>200</span>
@pytest.fixture def new_game_data(firestore_client): uid = "test_user_" + "".join( [random.choice(string.ascii_letters) for _ in range(6)] ) # create user data yield NewGameData( source=SOURCE, userId=uid, userUid=uid, forkUrl="test_url", ).dict() # cleanup game_id = create_game_id(SOURCE, uid) quest_id = create_quest_id(game_id, FIRST_QUEST_NAME) firestore_client.collection("game").document(game_id).delete() firestore_client.collection("quest").document(quest_id).delete() def test_fail_quest(firestore_client, new_game_post, new_game_data): """ Test situation where quest creation fails """ # check game and quest does not exist game_id = create_game_id(SOURCE, new_game_data["userId"]) quest_id = create_quest_id(game_id, FIRST_QUEST_NAME) firestore_client.collection("quest").document(quest_id).set({"_version": "999.9.9"}) # create! res = new_game_post(new_game_data) assert res.status_code == 500 def test_game_creation(firestore_client, new_game_post, new_game_data): """ Test successful game creation flow """ # check game and quest does not exist game_id = create_game_id(SOURCE, new_game_data["userId"]) quest_id = create_quest_id(game_id, FIRST_QUEST_NAME) game = firestore_client.collection("game").document(game_id).get() assert not game.exists quest = firestore_client.collection("quest").document(quest_id).get() assert not quest.exists # create! res = new_game_post(new_game_data) assert res.status_code == 200 # check if game actually created, and that it contains data game = firestore_client.collection("game").document(game_id).get() assert game.exists game_dict = game.to_dict() assert game_dict.items() >= new_game_data.items() # check if quest was created and that it contains data quest = firestore_client.collection("quest").document(quest_id).get() assert quest.exists # try create again, sohuld still work, but be idempotent res = new_game_post(new_game_data) assert res.status_code == 200

Enter fullscreen mode Exit fullscreen mode


It’s a little unclear, I’m beginning to feel that I need to significantly modularize and refactor this codebase at this point, as we now have game logic strewn across endpoints, a lot of duplicate boilercode. I have some ways in mind of how to deal with it, and will leave it for later.

LGTM (40 Part Series)

1 LGTM Devlog 0: Teaching Git through playing a game
2 LGTM Devlog 1: New Logo, and jumping the gun by buying a domain
36 more parts…
3 LGTM Devlog 2: Design Ideas for the game I
4 LGTM Devlog 3: Design Ideas for the game 2
5 LGTM Devlog 4: Technical Decisions – GitHub API and Python
6 LGTM Devlog 5: Technical Decisions – Serverless Architecture
7 LGTM Devlog 6: Sprint 1 plans
8 LGTM Devlog 7: Creating a Firebase project
9 LGTM Devlog 8: Starting a git repo for an open source project from scratch
10 LGTM Devlog 9: Python Google Cloud Functions with Unit Tests and Linting
11 LGTM Devlog 10: Capturing the GitHub webhook for fork requests
12 LGTM Devlog 11: Writing the Serverless Function for receiving GitHub webhooks with Pydantic validation
13 LGTM Devlog 12: CI/CD with GitHub Actions to run Unit Tests and deploy Firebase Functions
14 LGTM Devlog 13: GitHub Branch Protection and Security
15 LGTM Devlog 14: Sprint 1 Retrospective
16 LGTM Devlog 15: Sprint 2 plans
17 LGTM Devlog 16: A serverless data base access rule conundrum
18 LGTM Devlog 17: Website and GitHub OAuth
19 LGTM Devlog 18: Python Serverless functions using GitHub API to validate users
20 LGTM Devlog 19: Game data/quest storage
21 LGTM Devlog 20: Python Abstract Base Class-based data/quest storage
22 LGTM Devlog 21: Deploying Pub/Sub-triggered Python Google Cloud Functions
23 LGTM Devlog 22: Modularization
24 LGTM Devlog 23: Sprint 2 Retrospective
25 LGTM Devlog 24: Sprint 3 plans
26 LGTM Devlog 25: Some cleanup
27 LGTM Devlog 26: Python Graphlib DAGs for Quest Stages
28 LGTM Devlog 27: Branching quests
29 LGTM Devlog 28: Game event loop using Google Cloud Scheduler and PubSub
30 LGTM Devlog 29: ORM for Firestore and __init__subclass__ dunders and metaclasses
31 LGTM Devlog 30: Sprint 3 Retrospective
32 LGTM Devlog 31: Sprint 4 Plan
33 LGTM Devlog 32: Secrets Management to avoid storing API keys in services
34 LGTM Devlog 33: Using PyGithub to post GitHub issues and comments
35 LGTM Devlog 34: Characters Posting on GitHub Issues
36 LGTM Devlog 35: Responding to the player’s answers on GitHub Issues Comments
37 LGTM Devlog 36: Character profiles! On GitHub
38 LGTM Devlog 37: Sprint 4 Retrospective
39 LGTM Devlog 38: Sprint 5 Plan
40 LGTM Devlog 39: Planning the story

原文链接:LGTM Devlog 21: Deploying Pub/Sub-triggered Python Google Cloud Functions

© 版权声明
THE END
喜欢就支持一下吧
点赞11 分享
People do a lot of thinking, and sometimes, that's what kills us.
有时候是我们自己想太多才让自己如此难受
评论 抢沙发

请登录后发表评论

    暂无评论内容