Heute möchte ich kurz mein co2monitor.api-Projekt vorstellen. Dabei handelt es sich um eine REST-API, die es mir ermöglicht, die Messwerte meines CO2-Monitors zu speichern und auszulesen.

Zu lang - nicht gelesen

CO2-Monitor

Der CO2-Monitor

Bei meinem CO2-Monitor handelt es sich um den TFA Dostmann CO2-Monitor AIRCO2NTROL MINI 31.5006. Dieser misst neben der CO2-Konzentration auch die Temperatur. Die Messwerte werden abwechselnd auf einem kleinen Display angezeigt, der CO2-Wert wird daneben noch auf einer Ampel dargestellt, wobei grün bis 799ppm, orange bis 1199ppm und rot ab 1200ppm angezeigt wird. Wie man diesen auslesen kann, hat Henryk Plötz in All your base are belong to us ausführlich beschrieben. Mein CO2-Monitor ist über USB mit einem Raspberry Pi 3B verbunden, auf dem ich das folgende Auslese-Skript laufen lasse.

Das Auslese-Skript

Es gibt zahlreiche Umsetzungen der Auslesemechanik, ich habe mich damals für CO2Meter von heinemml entschieden. Mein Auslese-Skript bindet das CO2Meter-Skript ein, liest jede Minute die Daten aus und sendet diese dann an die REST-API. Clean-Code und Best-Practices sind in diesem Skript nicht zu finden, da ich es damals als Anfänger geschrieben habe und es seitdem nicht mehr angefasst habe. Ich habe es nur kurz angepasst, damit es mit der neuen co2monitor.api funktioniert.

Die alte REST-API

Die alte REST-API hatte ich in Python mit FastAPI umgesetzt. Sie hat die Messwerte in einer SQLite-Datenbank gespeichert und main konnte diese über eine GET-Route auslesen. Die alte API habe ich nun durch die neue co2monitor.api ersetzt.

Einmal alles neu

Durch die Ausbildung habe ich statisch typisierte Programmiersprachen zu schätzen gelernt. C# habe ich allerdings auf Arbeit zu genüge und ich wollte etwas neues, was ähnlich einfach wie Python zu verwenden, statisch typisiert und sehr schnell im Entwicklungsprozess ist. Deshalb habe ich mich für Go entschieden. Go ist eine statisch typisierte Programmiersprache, die sich durch ihre einfache Syntax und ihre Performance auszeichnet. Nach einem Crash­kurs (dieses Wort steht wirklich so im Duden… 🙈) mit Learn Go with Tests habe ich mich an die Arbeit gemacht.

Das Grundgerüst:

  • Gin Web Framework: Ein sehr einfaches und schnelles Web Framework für Go
  • GORM: Ein ORM für Go, da war ich von Entity Framework in C# verwöhnt
  • Compile Daemon for Go: Ein Daemon, der nach jedem Speichern automatisch neu kompiliert und den Server neustartet
  • Log: Ein Logger, der hervorragende Einstellungsmöglichkeiten bietet
  • PostgreSQL: Eine relationale Datenbank, die mir am meisten zusagt
  • Docker: Um die Anwendung in einem Container zu betreiben

Die Highlights der Umsetzung

Ich möchte hier niemanden langweilen, deswegen möchte ich nur kurz auf die Highlights der Umsetzung eingehen. Das Projekt ist auf GitHub zu finden, wer sich für die Details interessiert, kann sich gerne den Code ansehen.

Die Datenbankverbindung, das Erstellen des Schemas

Mittels GORM verbinde ich mich mit einer PostgreSQL-Datenbank. Ich bin davon ausgegangen, dass die Datenbank mit allen Tabellen automatisch von GORM erstellt wird. Dafür hatte ich die Verbindungsvorlage aus der GORM-Dokumentation verwendet.

func ConnectToDb() {
    dsn := os.Getenv("DATABASE_URL")
    db, err := gorm.Open(postgres.New(postgres.Config{
        DSN:                  dsn,
        PreferSimpleProtocol: false,
    }), &gorm.Config{
        Logger: logger.Default.LogMode(logger.Info),
        NamingStrategy: schema.NamingStrategy{
            TablePrefix:   "co2monitor.",
            SingularTable: false,
        },
    })

    if err != nil {
        log.Fatal("Failed to connect to database. \n", err)
    }

    // creating the schema

    log.Info("Connected to database.")

    DB = DbInstance{
        Db: db,
    }
}

Durch das Angeben des Schemas dachte ich, dass eben auch jenes erstellt werden würde. Das war allerdings nicht der Fall, auch durch db.AutoMigrate(&models) nicht. Ich habe dann die Funktion angepasst und erstelle jetzt das Schema manuell, wenn es nicht existiert.

    dbSchema := os.Getenv("POSTGRES_DB")
    createSchemaCommand := fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s;", dbSchema)
    result := db.Exec(createSchemaCommand)
    if result.Error != nil {
        log.Fatal("Failed to create schema. \n", result.Error)
    }

Das Routing und die Middleware

Da ich nicht möchte, dass jeder die API verwenden kann, habe ich eine Middleware eingebaut, die die Anfrage überprüft. Ich wollt ein ein Berechtigungskonzept, das GET- von POST-/PATCH- und DELETE-Route unterscheidet und nur mit verschiedenen Schlüsseln auf die jeweiligen Routen den Zugriff erlaubt. Die Middleware überprüft den im Header mitgelieferten API-Key und gibt bei einem ungültigen Schlüssel oder einem Zugriff auf eine Route ohne ausreichende Zugriffsrechte einen 401 Unauthorized zurück. Ich habe zuerst probiert, die Middlewares zu schachteln. Ich dachte, ich könnte eine Middleware als Higher-Order-Function schreiben, um den benötigten API-Key zu übergeben.

func locationRoutes(superRoute *gin.RouterGroup) {
    controllers := &controllers.APIEnv{
        DB: db.GetDB(),
    }

    locationRouter := superRoute.Group("/location")
    adminMiddleware := locationRouter.Group("/")
    adminMiddleware.Use(middleware.RequireAuth("X_API_KEY_ADMIN"))
    {
        adminMiddleware.POST("/new", controllers.CreateLocation)
        getMiddleware.GET("/", controllers.GetLocations)
        // weitere Methoden
    }

    normalMiddleware := locationRouter.Group("/")
    normalMiddleware.Use(middleware.RequireAuth("X_API_KEY"))
    {
        normalMiddleware.GET("/", controllers.GetLocations)
        // weitere Methoden
    }
}

Das hat aber für meinen Fall nicht funktioniert, bzw. hätte ich mich dann für die GET-Methoden wiederholen müssen, da auch jemand mit dem Admin-Schlüssel die GET-Routen abfragen können sollte. Deshalb habe ich die Middleware so umgesetzt, dass sie die Anfrage und den API-Key mit der entsprechenden Berechtigung überprüft oder einen 401 zurückgibt.

func RequireApiKey(c *gin.Context) {
    APIKey := c.Request.Header.Get("X-API-KEY")
    adminAPIKey := os.Getenv("X_API_KEY_ADMIN")
    normalAPIKey := os.Getenv("X_API_KEY")

    if APIKey == "" {
        c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Unauthorized"})
        log.Infof(`API-Key was not found or wrong. API-Key: <%s>; URL: <%s>`, APIKey, c.Request.URL)
        return
    }

    switch c.Request.Method {
    case http.MethodGet:
        if APIKey == adminAPIKey || APIKey == normalAPIKey {
            c.Next()
            return
        }
    case http.MethodPost, http.MethodPatch, http.MethodDelete:
        if APIKey == adminAPIKey {
            c.Next()
            return
        }
    }

    log.Infof(`Unauthorized API-Key. API-Key: <%s>; URL: <%s>; Method: <%s>; Path: <%s>`, APIKey, c.Request.URL, c.Request.Method, c.FullPath())
    c.AbortWithStatus(http.StatusUnauthorized)
}

Das hat mich dann auch zu einem deutlich cleaneren Routing geführt. 🥳

func locationRoutes(superRoute *gin.RouterGroup) {
    controllers := &controllers.APIEnv{
        DB: db.GetDB(),
    }

    locationRouter := superRoute.Group("/location")
    locationRouter.Use(middleware.RequireApiKey)
    {
        locationRouter.GET("/", controllers.GetLocations)
        locationRouter.GET("/search", controllers.GetLocationBySearch)
        locationRouter.POST("/new", controllers.CreateLocation)
        locationRouter.PATCH("/:id", controllers.UpdateLocation)
        locationRouter.DELETE("/:id", controllers.DeleteLocation)
    }
}

Die Unit-Tests

Unit-Tests

In meiner Ausbildung kam das Thema Unit-Tests leider zu kurz, ich halte Tests aber für essenziell für guten, wartbaren Code. Bei meinem jetzigen Arbeitgeber testen wir intensiv, deshalb war es auch mein Anspruch, die Anwendung mit Unit-Tests abzudecken. Das mitgelieferte Testing-Modul in Go ist meiner Meinung nach sehr gut, mit Testify wird es überragend, da Assertions als Einzeiler geschrieben werden können. Am meisten Kopfzerbrechen haben mir die Funktionen gemacht, die eine Datenbankanbindung verwenden. In .Net kenne ich die InMemory-Datenbank des SQLServers, die das Testen von Datenbankoperationen wirklich leicht macht. Die Erste Suche im Sinne von “Wie testet man Go-Funktionen, die gorm verwenden?” hatte leider nicht die besten Treffer. Einige exotische Vorschläge (🤔) wie TestContainer oder gegen eine echte Datenbank zu testen hatten mir kurzzeitig Sorgen bereitet, ich hätte doch kein GORM nutzen sollen. Dabei hätte ich gar nicht groß suchen müssen sondern einfach die GORM-Dokumentation zur Verbindung einer Datenbank weiterlesen müssen, dort wurde nämlich auf den Pure Go SQLite driver for GORM verwiesen. SQLite stellt die Möglichkeit einer InMemory-Datenbank zu Verfügung, wodurch ich meinen Datenbank-Mock schreiben konnte.

type BaseFixture struct {
    Db *gorm.DB
}

func (f *BaseFixture) Setup(t *testing.T) {
    var err error
    f.Db, err = gorm.Open(sqlite.Open(":memory:?_pragma=foreign_keys(1)"), &gorm.Config{})
    require.NoError(t, err)
    f.Db.AutoMigrate(&models.Location{}, &models.Co2Data{})
}

func (f *BaseFixture) Teardown(t *testing.T) {
    f.Db.Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&models.Location{}, &models.Co2Data{})
}

func (f *BaseFixture) AddDummyData(t *testing.T) {
    f.Db.Create(&Locations)
    f.Db.Create(&CO2)
}

Die SQLite-Datenbank wird für jeden Test neu erstellt und nach dem Test wieder gelöscht. Außerdem habe ich eine Funktion geschrieben, die Dummy-Daten in die Datenbank schreibt, damit ich diese in den Tests verwenden kann. Ein Test sieht dann beispielsweise so aus:

func TestGetLocationBySearch_ShouldFindOneById(t *testing.T) {
    f := tests.BaseFixture{}
    f.Setup(t)
    f.AddDummyData(t)
    defer f.Teardown(t)

    result, err := db_calls.GetLocationBySearch(f.Db, "2", "")

    require.NoError(t, err)
    assert.Equal(t, tests.Locations[1].Name, result[0].Name)
    assert.Equal(t, 1, len(result))
}

Unit-Tests ✅

Swagger

Durch die einfache Einbindung von Swagger in .Net bin ich es gewohnt, dass ich mit einem Klick die Dokumentation meiner API habe. Auch für gin-gonic gibt es eine einfache Möglichkeit, Swagger zu verwenden, indem man gin-swagger einbindet. Schreibt man dann noch die Dokumentation der Routen entsprechend als Kommentar über die Funktionen hat man mit einem Klick die Dokumentation. 🧐

Swagger

Deployment und Hosting

Wie bereits in meinem vorherigen Blogeintrag Hosting mit Oracle Cloud Free Tier läuft die API in einem Docker-Container auf einer Oracle Cloud Free Tier Compute-Instance. Die API ist über co2.leyrer.io erreichbar und dokumentiert.

Fazit

Ich habe eine REST-API in Go geschrieben, die ich mit Unit-Tests abgedeckt habe und die ich in einem Docker-Container betreiben kann. Ich habe viel gelernt und bin sehr zufrieden mit dem Ergebnis. 🥳