Skip to content

Commit

Permalink
Merge branch 'main' into TELESTION-465
Browse files Browse the repository at this point in the history
  • Loading branch information
pklaschka authored Jan 6, 2024
2 parents 89e38d2 + 163f3f3 commit ef9aa1d
Show file tree
Hide file tree
Showing 20 changed files with 1,109 additions and 0 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/backend-go-ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Backend Go CI

# Events that trigger this workflow
on: [ push, pull_request ]

defaults:
run:
working-directory: ./backend-go

jobs:
style:
name: Style
runs-on: ubuntu-latest
steps:
- name: Checkout 📥
uses: actions/[email protected]
- name: Check style 🧽
run: docker compose --file docker-compose.ci.yml --profile style up --abort-on-container-exit
- name: Stop containers 🛑
if: always()
run: docker compose --file docker-compose.ci.yml --profile style down

test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout 📥
uses: actions/[email protected]
- name: Run tests 🛃
run: docker compose --file docker-compose.ci.yml --profile test up --abort-on-container-exit
- name: Stop containers 🛑
if: always()
run: docker compose --file docker-compose.ci.yml --profile test down
6 changes: 6 additions & 0 deletions backend-go/.nats.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
http_port: 8222

websocket: {
port: 9222
no_tls: true
}
9 changes: 9 additions & 0 deletions backend-go/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
FROM golang:1.21-alpine

# creates an "invisible" docker volume during container startup
# by retaining the go builc cache from the image build
# to support different cpu architectures
VOLUME /go

# switch to app
WORKDIR /app
21 changes: 21 additions & 0 deletions backend-go/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024 WüSpace e. V.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
70 changes: 70 additions & 0 deletions backend-go/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Telestion Service Framework for Go

[![DOI: 10.5281/zenodo.10407142](https://zenodo.org/badge/DOI/10.5281/zenodo.10407142.svg)](https://doi.org/10.5281/zenodo.10407142)
![GitHub License: MIT](https://img.shields.io/github/license/wuespace/telestion)

This library provides a framework for building Telestion services in Go.

## Installation

Install the library via `go get`:

```shell
go get -u github.com/wuespace/telestion/backend-go@latest
```

## Basic Usage

```go
package main

import (
"github.com/wuespace/telestion/backend-go"
"log"
)

type Person struct {
Name string `json:"name"`
Address string `json:"address"`
}

func main() {
// start a new Telestion service
service, err := telestion.StartService()
if err != nil {
log.Fatal(err)
}
log.Println("Service started")

// publish a message on the message bus
service.Nc.Publish("my-topic", []byte("Hello from Go!"))

// subscribe to receive messages from the message bus
// automatically unmarshal JSON message to go struct
_, err = service.NcJson.Subscribe("registered-person-topic", func(person *Person) {
log.Println("Received new personal information:", person)
})
if err != nil {
log.Println(err)
}

// wait for interrupts to prevent immediate shutdown of service
telestion.WaitForInterrupt()

// drain remaining messages and close connection
if err1, err2 := service.Drain(); err1 != nil || err2 != nil {
log.Fatal(err1, err2)
}
}
```

## Behavior Specification

The behavior of this library is specified in
the [Behavior Specification](https://docs.telestion.wuespace.de/Backend%20Development/service-behavior/).
This specification is also used to test the library.
The source code of the tests can be found in the repository under `/backend-features`.

## License

This project is licensed under the terms of the [MIT license](LICENSE).
232 changes: 232 additions & 0 deletions backend-go/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package telestion

import (
"encoding/json"
"flag"
"fmt"
"github.com/mitchellh/mapstructure"
"github.com/nats-io/nats.go"
"log"
"os"
"path/filepath"
"strings"
)

// Config parsing process must at least yield the following minimal config scheme
type minimalConfig struct {
NatsUrl string `mapstructure:"NATS_URL"`
ServiceName string `mapstructure:"SERVICE_NAME"`
DataDir string `mapstructure:"DATA_DIR"`
}

// Checks if the untyped map contains all required config parameters to successfully start the service.
func assertContainsMinimalConfig(mapping map[string]any) error {
mConf := minimalConfig{}

decoderConfig := &mapstructure.DecoderConfig{
ErrorUnused: false,
ErrorUnset: true,
Result: &mConf,
}

decoder, err := mapstructure.NewDecoder(decoderConfig)
if err != nil {
// Decoder for minimal config inference could not be initialized!
return err
}

if err := decoder.Decode(mapping); err != nil {
// Minimal config could not be inferred from given map!
return fmt.Errorf("missing parameters in configuration. "+
"The following parameters are required: NATS_URL, SERVICE_NAME, DATA_DIR. "+
"Consider using --dev during development. Original error message: %s", err.Error())
}

return nil
}

// Parses an untyped map into a service configuration.
func parseConfig(mapping *map[string]any) (*Config, error) {
// gets populated by the mapstructure decoder
config := Config{}

decoderConfig := &mapstructure.DecoderConfig{
ErrorUnused: false,
ErrorUnset: false,
WeaklyTypedInput: true,
Result: &config,
}

decoder, err := mapstructure.NewDecoder(decoderConfig)
if err != nil {
// Decoder for TelestionBaseConfig inference could not be initialized!
return nil, err
}

if err := decoder.Decode(*mapping); err != nil {
// TelestionBaseConfig could not be inferred from given map!
return nil, err
}

return &config, nil
}

// Loads and parses the service [Config] from different configuration sources in the following order:
//
// 1. `overwriteArgs`
// 2. command line arguments
// 3. environment variables
// 4. default configuration, if `--dev` is passed in the steps from above
// 5. configuration file, if `CONFIG_FILE` parameter is defined, readable and parsable
func assembleConfig(overwriteArgs map[string]string) (*Config, error) {
config := &map[string]any{}

// add config params from passed service options
updateWith(config, &overwriteArgs)
// add config params from command line arguments
updateWith(config, cliConfig())
// add config params from environment variables
updateWith(config, envConfig())

// add default config if "dev" configuration is defined
if dev, ok := (*config)["DEV"].(bool); ok && dev {
fmt.Println("Running in development mode. Using default values for missing environment variables.")
dc, err := devModeDefaultConfig()
if err != nil {
return nil, err
}
updateWith(config, dc)
}

// add config file parameters if "CONFIG_FILE" is defined and readable
if configPath, ok := (*config)["CONFIG_FILE"].(string); ok && len(configPath) != 0 {
fc, err := fileConfig(configPath)
if err != nil {
return nil, err
}
updateWith(config, fc)
}

// verify if configuration is valid
if err := assertContainsMinimalConfig(*config); err != nil {
return nil, err
}

return parseConfig(config)
}

// Adds entries from updates to base that don't exist in base.
func updateWith[V any | string](base *map[string]any, updates *map[string]V) {
for k, v := range *updates {
if _, contained := (*base)[k]; !contained {
(*base)[k] = v
}
}
}

// Parses the console arguments and returns a map that holds the configuration parameters.
func cliConfig() *map[string]any {
// setup flags
var (
dev bool
natsUrl string
natsUser string
natsPassword string
configFile string
configKey string
serviceName string
dataDir string
)

flag.BoolVar(&dev, "dev", false, "If set, program will start in development mode")

flag.StringVar(&natsUrl, "NATS_URL", "", "NATS url of the server the service can connect to")
flag.StringVar(&natsUser, "NATS_USER", "", "NATS user name for the authentication with the server")
flag.StringVar(&natsPassword, "NATS_PASSWORD", "", "NATS password for the authentication with the server "+
"(Note: It is recommended to set this via the environment variables or the config!)")

flag.StringVar(&configFile, "CONFIG_FILE", "", "file path to the config of the service")
flag.StringVar(&configKey, "CONFIG_KEY", "", "object key of a config file")

flag.StringVar(&serviceName, "SERVICE_NAME", "", "name of the service also used in the nats service "+
"registration")
flag.StringVar(&dataDir, "DATA_DIR", "", "path where the service can store persistent data")

// we don't really like the default message of the flag package
flag.Usage = func() {
fmt.Printf("Usage: %s [options] [field_0 ... field_n]\n\nParameters:\n", os.Args[0])
flag.PrintDefaults()
}
flag.Parse()

flagValues := map[string]any{
"NATS_URL": natsUrl,
"NATS_USER": natsUser,
"NATS_PASSWORD": natsPassword,
"CONFIG_FILE": configFile,
"CONFIG_KEY": configKey,
"SERVICE_NAME": serviceName,
"DATA_DIR": dataDir,
}

// prepare output map
parsedArgs := map[string]any{
"DEV": dev,
}

// only populate parsedArgs with entries that were, indeed, given (dev is an exception)
flag.Visit(func(currentFlag *flag.Flag) {
if value, ok := flagValues[currentFlag.Name]; ok {
parsedArgs[currentFlag.Name] = value
}
})

return &parsedArgs
}

// Read the environment variables and provides them as map ready to be included in the service config.
func envConfig() *map[string]string {
result := make(map[string]string, len(os.Environ()))
for _, entry := range os.Environ() {
if key, value, ok := strings.Cut(entry, "="); ok {
result[key] = value
}
// we don't want to add empty env variables
}
return &result
}

// Tries to read the configuration file and returns the content as untyped map.
// Fails, if the config file is not readable or if the content is not JSON parsable.
func fileConfig(configPath string) (*map[string]any, error) {
// Note that the file config is supposed to be a json config
jsonConfig := map[string]any{}
jsonConfigBytes, err := os.ReadFile(configPath)

if err != nil {
log.Printf("Config file %s could not be read: %s\n", configPath, err)
return nil, err
}

if err = json.Unmarshal(jsonConfigBytes, &jsonConfig); err != nil {
log.Printf("Config file %s could not be parsed: %s\n", configPath, err)
return nil, err
}

return &jsonConfig, nil
}

// Returns the default configuration for development purposes.
// Fails, if the process is not allowed to determine the current working directory.
func devModeDefaultConfig() (*map[string]string, error) {
dataDir, err := filepath.Abs("data")
if err != nil {
return nil, err
}

return &map[string]string{
"NATS_URL": nats.DefaultURL,
"SERVICE_NAME": fmt.Sprint("dev-", os.Getgid()),
"DATA_DIR": dataDir,
}, nil
}
Loading

0 comments on commit ef9aa1d

Please sign in to comment.