DEV Community

Cover image for LGTM Devlog 22: Modularization
Yuan Gao
Yuan Gao

Posted on

LGTM Devlog 22: Modularization

So, the codebase got to a point where all the functionality was coming together, but in a way that was a mess. There was duplicate code strewn about, functions that triggered other cloud functions unnecessarily when it could have just called the code directly rather than be de-coupled via duplicate code. So I took the time to refactor everything. Something that takes a lot of time, but I feel was necessary.

The end result is significantly simpler and easier to read, even if it has more complete functionality. The test-suite makes more sense, and so the end result was good. I don't think I could have arrived at this without having gone through building it up in a messy way first, so I don't regret havign to do major refactoring now.

Let me share what I've ended up with. The code can be found at commit 415ce36.

Structure

Instead of having effectively three different functions projects (github_webhook_lister, github_auth_flow, and create_new_game), I have combined them into one project: game_core. Multiple cloud functions deploy from it, but it's the same codebase and same set of shared python modules, reducing duplicate code and the need to manually ensure things work together correctly.

Instead, everything lives under the game_core/app directory as a set of python modules that each handle their own concerns, and a single main.py where things are tied together under functionality relating to the endpoints.

New code structure

This separation of concerns is a lot more straightforward to understand, I'll explain them each in detail:

Firebase Utils

This is the smallest package, it consists of just initialization for firebase_admin package. The reason for this is because the Firebase app is stateful, and needs to be initialized, but will complain if initialized twice, unless giving them different names. While we could initialize a new instance of firebase everywhere we need it, this is not a good use of resources, so we need a singleton instead. While singletons are usually considered a bad idea, but the exception here is that we do in fact want to have only one instance of firebase because under the hood it is could be maintaining a single set of connections to the database (though actually we don't know what it's doing under there, but it stands to reason that this instance of firebase represents a database resource).

There's two ways we can do this, and I've accidentally used both without thinking. The first way is with a module (which is what I've done here). Python modules are singletons by nature - they will only be imported once, and therefore initializing firebase in here means it will be the same object that is being imported elsewhere in the app. I have mixed feelings about this approach. It's certainly neat, and has become a de-facto method in Python that many devs are familiar with, but it does somewhat rely on implicit knowledge of how Python imports work. Ironically, firebase's own library relies on module singletons to work, so it's not really unprecedented.

The second way, which I've also implemented, is an undocumented and unofficial method of testing to see if firebase has already been initialized (since it knows, because firebase library under the hood is also a module singleton). So in the end the module contains this:

import firebase_admin  # type:  ignore
from firebase_admin import firestore  # type:  ignore

if firebase_admin._apps:
    app = firebase_admin.get_app()
else:
    app = firebase_admin.initialize_app()
db = firestore.client()
Enter fullscreen mode Exit fullscreen mode

In reality that first if statement will never evaluate true because this module will never be executed twice even though it's imported by different other modules.

Weird huh. But I'm leaving it.

Game

The game package contains a single class Game which represents our game entity. It's got a few class methods to it: Game.new(user, fork_url) which will create a new game in firestore, and assign it to the user identified by the user argument, and Game.find_by_user(user) which performs a search in the database to find an existing game for a user. This search is necessary to link up existing games that have already been started but a user has just signed up on the website and their account isn't yet linked to the game. This is made convenient because in theory there should only be a 1:1 relationship between user accounts and games. Players shouldn't be able to start more than one game (at least not for now).

An instance of Game() itself actually only contains a couple of keys that reference an entity in the database. Any methods then act on the database using these keys. So the expectation is that instances of Game() are returned by the class functions Game.new() or Game.find_by_user() rather than be instantiated directly. Any calls to instance methods will act upon the database.

The only instance method right now is assign_to_uid() which allows us to assign games to a specific user's UID, to "link" it with a user account.

The structure looks like this:

class Game:
    @classmethod
    def new(cls, user: User, fork_url: str) -> Game:
        """ Create a new game if doesn't exist, return reference to it """
        ...

    @classmethod
    def find_by_user(cls, user: User) -> Optional[Game]:
        """ Find a game by user_key and return ref object for it, or None """
        ...

    user: Optional[User] = None

    @property
    def key(self) -> str:
        ...

    def assign_to_uid(self, uid: str) -> None:
        ...

    def __repr__(self):
        ...
Enter fullscreen mode Exit fullscreen mode

A test suite exists to exercise this Game class, with functions that run each of the creation. The tests also have fixtures to create User entities in order to correctly test the game, as well as code to clean up the tests created, for example, the testing_game() fixture is below:

@pytest.fixture(scope="package")
def testing_game(testing_user):
    """ A random game for testing, cleans up afterwards """
    fork_url = "url_" + "".join(
        [random.choice(string.ascii_letters) for _ in range(10)]
    )

    game = Game.new(testing_user, fork_url)
    yield game

    # cleanup
    db.collection("game").document(game.key).delete()
    db.collection("system").document("stats").update({"games": firestore.Increment(-1)})

    # cleanup auto-created quest too
    QuestClass = Quest.get_first()
    quest = QuestClass()
    quest.game = game
    db.collection("quest").document(quest.key).delete()
Enter fullscreen mode Exit fullscreen mode

In the future, I will use Firebase's emulation suite, which should remove the need to clean up tests data afterwards. But for now, these tests happen on the production database (which is dangerous, but...we'll survive).

Github Utils

I've moved github-related functions to it's own package, this just contains the pydantic models and webhook secrets validation code that I put together previously

Quest

The Quest system is very similar to what I described previously, with it's quest importer. The main difference is I've now provided a Quest class that works similarly to the Game object above. It has the following structure:


class Quest(ABC):
    @classmethod
    def get_by_name(cls, name: str) -> Type[Quest]:
        ...

    @classmethod
    def get_first(cls) -> Type[Quest]:
        ...

    @classmethod
    def new(cls, game: Game) -> Quest:
        ...

    @property
    @abstractmethod
    def version(cls) -> VersionInfo:
        return NotImplemented

    @property
    @abstractmethod
    def difficulty(cls) -> Difficulty:
        return NotImplemented

    @property
    @abstractmethod
    def description(cls) -> str:
        return NotImplemented

    default_data: ClassVar[Dict[str, Any]] = {}
    quest_data: Dict[str, Any] = {}
    VERSION_KEY = "_version"
    NAME_KEY = "_name"
    game: Optional[Game] = None

    def __init_subclass__(self):
        ...

    @property
    def key(self) -> str:
        ...

    def load(self, save_data: Dict[str, Any]) -> None:
        ...

    def get_save_data(self) -> Dict[str, Any]:
        ...

    def __repr__(self):
        ...
Enter fullscreen mode Exit fullscreen mode

The get_by_name() class method is the same get_quest_by_name() function defined previously, while get_first() now takes on the duties of returning the pre-defined "first quest".

The new() class method works similarly to the Game.new() function, in that it creates entities in the database and returns to us a quest object that represents that entity in thedatabase should we need to do further things to it. In fact, this class method is called during Game.new() since a new first quest should be started at the start of the game.

The rest of the properties remain the same as last time as the Quest object is also the abstract base class for the other quests.

As with Game, there's a test-suite for Quest that exercises it, including all the loading we had before, but this time also tests for quest creation in the database.

User

This module contains the User class, again, structured very similarly to Game, with a new class method that creates user entities in the database. It also has a reference method that returns a User instance without doing the creation, which is necessary in some cases because games can be started without users having done a signup, so we still need a "dummy" User object that contains the right references to a user, without creating the user in the database. I'm still considering whether I should allow this way of using the User class or not. things will get clearer when I begin implementing features, I may change my mind.

The structure is:

class User:
    @classmethod
    def reference(cls, source: Source, user_id: str) -> User:
        ...

    @classmethod
    def new(cls, uid: str, source: Source, user_data: UserData) -> User:
        ...

    @classmethod
    def find_by_source_id(cls, source: Source, user_id: str) -> Optional[User]:
        ...

    def __init__(self, source: Source, user_id: str):
        ...

    @property
    def key(self) -> str:
        ...

    def __repr__(self) -> str:
        ...

Enter fullscreen mode Exit fullscreen mode

Again, tests exists to exercise the User clas.

main.py

Finally, all there functions have been combined into a single main.py file. The complexity of these functions are now much reduced, since the mechanics of user and game creation have been abstracted away. In fact, it's been abstracted enough that we can eliminate the separate create_new_game endpoint, since it's now a one-liner.

github_webhook_listener

The webhook listener in main.py now looks like this:

def github_webhook_listener(request: Request):
    """ A listener for github webhooks """

    # verify
    if not verify_signature(request):
        logger.error("Invalid signature")
        return jsonify(error="Invalid signature"), 403

    # decode
    try:
        hook_fork = GitHubHookFork.parse_raw(request.data)
    except ValidationError as err:
        logger.error("Validation error", err=err)
        return jsonify(error="Validation error"), 400
    user_id = str(hook_fork.forkee.owner.id)

    # check repo is ours
    if hook_fork.repository.full_name != OUR_REPO:
        logger.error("Not our repo!", repo=hook_fork.repository.full_name)
        return jsonify(error="Invalid repo"), 400

    logger.info("Got fork", data=hook_fork.dict())

    # create a user reference, and then create new game
    user = User.find_by_source_id(Source.GITHUB, user_id)
    if not user:
        user = User.reference(Source.GITHUB, user_id)

    game = Game.new(user, hook_fork.forkee.url)
    logger.info("Created new game for user", game=game, user=user)

    return jsonify(status="ok", user_id=user_id)
Enter fullscreen mode Exit fullscreen mode

The verification and decoding steps are still the same, as they are very endpoint-related (not a function of any of the other modules - Game, User, Quest), but at the bottom, the core functionality of finding a user if it exists, and creating a new game has been reduced to those few lines.

Much of the tests defined before remain unchanged.

github_auth_flow

The auth flow function in main.py now looks like this:


def github_auth_flow(request: Request):
    """ Validates a user from github and creates user """

    # CORS headers
    if request.method == "OPTIONS":
        return ("", 204, CORS_HEADERS)

    # authenticate user
    token = request.headers.get("Authorization", "").removeprefix("Bearer ")
    try:
        decoded_token = verify_id_token(token)
    except (
        ValueError,
        InvalidIdTokenError,
        ExpiredIdTokenError,
        RevokedIdTokenError,
    ) as err:
        logger.warn("Authentication error", err=err)
        return jsonify(error="Authentication error"), 403
    uid = decoded_token["uid"]
    logger.info("Got authenticated user", decoded_token=decoded_token, uid=uid)

    # decode
    try:
        user_data = UserData.parse_raw(request.data)
    except ValidationError as err:
        logger.warn("Validation error", err=err)
        return jsonify(error="Validation error"), 400
    logger.info("Got user data", user_data=user_data)

    # authenticate GitHub
    github = Github(user_data.accessToken)

    try:
        user_id = github.get_user().id
    except BadCredentialsException as err:
        logger.warn("Bad Github credential", err=err)
        return jsonify(error="Bad GitHub credential"), 400

    if user_id != int(user_data.id):
        return jsonify(error="ID mismatch"), 400
    logger.info("Got github ID", user_id=user_id)

    # create new user, and find existing game to assign uid to
    user = User.new(uid=uid, source=Source.GITHUB, user_data=user_data)
    game = Game.find_by_user(user)
    if game:
        game.assign_to_uid(uid)
    logger.info("Results creating new user and finding game", game=game, user=user)

    return {"ok": True}, 200, CORS_HEADERS
Enter fullscreen mode Exit fullscreen mode

Again, a lot of this function relates specifically to tasks that the endpoint must do to validate the data coming in, so they haven't been broken out into separate functions. Where before, we triggered a user creation function using pubsub, here we can just call the (now-shared) User class to do this for us. This is much cleaner, and there was no benefit to decoupling it for now.

Testes from before are largely unchanged


The result of all this cleanup means we drop from three github workflows to a single one, and code is cleaner and more modular. Test coverage is also good for now. It's not a guarantee we don't have bugs, but it does exercise the main features, and will help with future development work

Test coverage results

The missing line 5 in firebase_utils corresponds to that unused if statement because the modules are singleton, and so it never has an opportunity to re-use itself. I can probably remove that if statement.

The missing lines in app/main.py relate to a error handling when something that's not our repo is reported in the webohok, some CORS-related code, and error handling for a couple of edge-cases that I need to write an additional test for but also generate a valid test payload in order to reach, something I'll do later.

So, with all this in place, we now have the framework for quest and user storage! Though quests have no functionality at all, and there is no game loop, that is for next sprint.

Discussion (0)