diff options
author | Benjamin Chausse <benjamin@chausse.xyz> | 2025-02-03 01:12:45 -0500 |
---|---|---|
committer | Benjamin Chausse <benjamin@chausse.xyz> | 2025-02-03 01:12:45 -0500 |
commit | 5389e1a5d26fdbf2441fa5a1e101999e8449b9d1 (patch) | |
tree | 069cd37cb8e556c1ba3b47c3ea8576a1aa91ea2c |
Batman
-rw-r--r-- | .gitignore | 1 | ||||
-rw-r--r-- | .gitmodules | 3 | ||||
-rw-r--r-- | Makefile | 30 | ||||
-rw-r--r-- | README.md | 99 | ||||
-rw-r--r-- | assets/logo.png | bin | 0 -> 826324 bytes | |||
m--------- | external/protobuf | 0 | ||||
-rw-r--r-- | go.mod | 32 | ||||
-rw-r--r-- | go.sum | 78 | ||||
-rw-r--r-- | internal/app/action.go | 47 | ||||
-rw-r--r-- | internal/app/command.go | 23 | ||||
-rw-r--r-- | internal/app/flags.go | 127 | ||||
-rw-r--r-- | internal/logging/context.go | 39 | ||||
-rw-r--r-- | internal/logging/logging.go | 92 | ||||
-rw-r--r-- | internal/logging/trace.go | 61 | ||||
-rw-r--r-- | internal/manualgen/manualgen.go | 21 | ||||
-rw-r--r-- | internal/server/model/.gitignore | 2 | ||||
-rw-r--r-- | internal/server/server.go | 16 | ||||
-rw-r--r-- | internal/server/setup.go | 34 | ||||
-rw-r--r-- | internal/server/task.go | 33 | ||||
-rw-r--r-- | internal/server/user.go | 34 | ||||
-rw-r--r-- | internal/storage/db.go | 18 | ||||
-rw-r--r-- | internal/storage/schema.go | 38 | ||||
-rw-r--r-- | internal/storage/setup.go | 201 | ||||
-rw-r--r-- | internal/tagging/tagging.go | 23 | ||||
-rw-r--r-- | internal/util/context.go | 8 | ||||
-rw-r--r-- | main.go | 19 | ||||
-rwxr-xr-x | resources/local_dev.sh | 19 | ||||
-rw-r--r-- | resources/schema.proto | 79 | ||||
-rw-r--r-- | runtime/.gitignore | 2 |
29 files changed, 1179 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d58ae36 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "external/protobuf"] + path = external/protobuf + url = https://github.com/protocolbuffers/protobuf.git diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7ef18f3 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +BUILD_DIR=./build +APP=rafta + +all: setup codegen clean compile + +setup: + git submodule update --init + +codegen: + protoc \ + --proto_path=resources \ + --proto_path=external \ + --go_out=internal/server/model \ + --go_opt=paths=source_relative \ + --go-grpc_out=internal/server/model \ + --go-grpc_opt=paths=source_relative \ + resources/schema.proto + +clean: + rm -rf $(BUILD_DIR) || exit 1 + +compile: + mkdir -p $(BUILD_DIR) || exit 1 + CGO_ENABLED=1 go run ./internal/manualgen > $(BUILD_DIR)/$(APP).1 + CGO_ENABLED=1 go build -o $(BUILD_DIR)/$(APP) . + +.PHONY: run +run: + ./resources/local_dev.sh + diff --git a/README.md b/README.md new file mode 100644 index 0000000..eef8ea6 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +<div align="center"> + + <img alt="A raft (boat) in a sea of tasks (checkmarks) logo" src="assets/logo.png" width="250px" /> + +# Rafta + +**R**eally, **A**nother **F***cking **T**odo **A**pp?! + +</div> + + +## Why another TODO app? + +Many open source implementations for ToDo apps go with a file-based approach +(ex: task-warrior, todo-txt, orgmode, markdown). While this simplicity is +elegant, it usually results in implementations that is lacking in the +following scenarios: + +- Timely cross-device syncing +- Notifications (for reminders and due tasks) +- Good mobile interfaces + +This is one of the reason many people prefer a server based approach (ex: +Todoist, Apple reminders, Evernote, TickTick). However, these tend to be much +more complex and gravitate towards a proprietary, for-profit solution. This +makes them generally harder to hack around when it comes to creating custom +clients + +This project aims to get the best of both worlds: Cross platform reliability of +a server-based approach with the hackability of scrappy text-based management. + +## Goals + +This project stems from my frustration trying to find a decent Task management +infrastructure that fits my needs. So the project's primary goals are to +fulfill the following needs: + +- Easy synchronisation between multiple clients (mobile desktop and such) +- Neovim plugin as a desktop client (with a similar feel to [oil.nvim][1]) +- Pretty mobile interface (most txt-based apps are quite ugly IMO) +- Notifications that are on-time on mobile +- Easy extensibility through third-party clients (both daemons & user-facing) + +## Implementation + +So what's the plan to achieve this? This repo contains the implementation +of a server as well as the communication specification to that server. +Everyone is welcome to create their own frontend for that server as well as +other tooling that can communicate with said server. Examples of that could be + +- mobile/desktop app +- web app +- text-editor plugin +- conky/uebersicht desktop task viewer +- Web Endpoint for an iCal calendar +- webhook server + +As this project is in its infancy, specifications are subject to change but +this is bound to diminish over time. Communications are done over a gRPC +specification that can be found in the `resources/schema.proto` file. + +The end result would be something similar to this: + +```mermaid +flowchart TD + Srv[Self-hosted Rafta Server] + A[Mobile App] + B[Neovim plugin] + C[TUI App] + D[Web App] + E[iCal generator] + Srv <--> |gRPC| A + Srv <--> |gRPC| B + Srv <--> |gRPC| C + Srv <--> |gRPC| D + Srv <--> |gRPC| E +``` + +Since the aim is for each user to self host his task (and at most the task of a +few friends), a single container approach with an SQLite storage solution will +be implemented. + +The centerpiece is the server itself so but as it's development matures, I will +try and develop a client in Neovim as well as a mobile client. + +## Roadmap + +- [X] Protobuf schema definition +- [X] Blank Database Initialization +- [X] Request-ID tracking middleware +- [X] Logging framework (request-ID, stack traces, optional Json) +- [ ] **Actual Server Logic** +- [ ] Task re-scheduling routine +- [ ] User Authentication (probably jwt) +- [ ] Neovim plugin client + + + +[1]: https://github.com/stevearc/oil.nvim diff --git a/assets/logo.png b/assets/logo.png Binary files differnew file mode 100644 index 0000000..ec96600 --- /dev/null +++ b/assets/logo.png diff --git a/external/protobuf b/external/protobuf new file mode 160000 +Subproject ae3015c78a0ea3baa76f3ad556c0ecd575f2e61 @@ -0,0 +1,32 @@ +module github.com/ChausseBenjamin/rafta + +go 1.23.4 + +require ( + github.com/charmbracelet/log v0.4.0 + github.com/hashicorp/go-uuid v1.0.3 + github.com/mattn/go-sqlite3 v1.14.24 + github.com/urfave/cli-docs/v3 v3.0.0-alpha6 + github.com/urfave/cli/v3 v3.0.0-beta1 + google.golang.org/grpc v1.70.0 + google.golang.org/protobuf v1.36.4 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/lipgloss v0.10.0 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/go-logfmt/logfmt v0.6.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect + golang.org/x/net v0.32.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a // indirect +) @@ -0,0 +1,78 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= +github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= +github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM= +github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= +github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/urfave/cli-docs/v3 v3.0.0-alpha6 h1:w/l/N0xw1rO/aHRIGXJ0lDwwYFOzilup1qGvIytP3BI= +github.com/urfave/cli-docs/v3 v3.0.0-alpha6/go.mod h1:p7Z4lg8FSTrPB9GTaNyTrK3ygffHZcK3w0cU2VE+mzU= +github.com/urfave/cli/v3 v3.0.0-beta1 h1:6DTaaUarcM0wX7qj5Hcvs+5Dm3dyUTBbEwIWAjcw9Zg= +github.com/urfave/cli/v3 v3.0.0-beta1/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y= +go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U= +go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= +go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M= +go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= +go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= +go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= +go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= +go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= +go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM= +go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= +golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a h1:hgh8P4EuoxpsuKMXX/To36nOFD7vixReXgn8lPGnt+o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= +google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= +google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= +google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/action.go b/internal/app/action.go new file mode 100644 index 0000000..b579253 --- /dev/null +++ b/internal/app/action.go @@ -0,0 +1,47 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + + "github.com/ChausseBenjamin/rafta/internal/logging" + "github.com/ChausseBenjamin/rafta/internal/server" + "github.com/ChausseBenjamin/rafta/internal/storage" + "github.com/urfave/cli/v3" +) + +func action(ctx context.Context, cmd *cli.Command) error { + err := logging.Setup( + cmd.String(FlagLogLevel), + cmd.String(FlagLogFormat), + cmd.String(FlagLogOutput), + ) + if err != nil { + slog.Warn("Error(s) occured during logger initialization", logging.ErrKey, err) + } + + slog.Info("Starting rafta server") + + // TODO: Setup the db + store, err := storage.Setup(cmd.String(FlagDBPath)) + if err != nil { + slog.Error("Unable to setup database", logging.ErrKey, err) + } + + srv, lis, err := server.Setup(cmd.Int(FlagListenPort), store) + if err != nil { + slog.Error("Unable to setup server", logging.ErrKey, err) + + return err + } + + slog.Info(fmt.Sprintf("Listening on port %d", cmd.Int(FlagListenPort))) + if err := srv.Serve(lis); err != nil { + slog.Error("Server runtime error", logging.ErrKey, err) + + return err + } + + return nil +} diff --git a/internal/app/command.go b/internal/app/command.go new file mode 100644 index 0000000..5c32ba2 --- /dev/null +++ b/internal/app/command.go @@ -0,0 +1,23 @@ +package app + +import ( + "github.com/urfave/cli/v3" +) + +const ( + AppName = "rafta" + AppUsage = "Really, Another Freaking Todo App?!" +) + +var version = "COMPILED" + +func Command() *cli.Command { + return &cli.Command{ + Name: AppName, + Usage: AppUsage, + Authors: []any{"Benjamin Chausse <benjamin@chausse.xyz>"}, + Version: version, + Flags: flags(), + Action: action, + } +} diff --git a/internal/app/flags.go b/internal/app/flags.go new file mode 100644 index 0000000..e96b8ef --- /dev/null +++ b/internal/app/flags.go @@ -0,0 +1,127 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + + "github.com/ChausseBenjamin/rafta/internal/logging" + "github.com/ChausseBenjamin/rafta/internal/server" + "github.com/urfave/cli/v3" +) + +const ( + FlagListenPort = "port" + FlagLogLevel = "log-level" + FlagLogFormat = "log-format" + FlagLogOutput = "log-output" + FlagDBPath = "database" +) + +func flags() []cli.Flag { + return []cli.Flag{ + // Logging {{{ + &cli.StringFlag{ + Name: FlagLogFormat, + Aliases: []string{"f"}, + Value: "plain", + Usage: "plain, json", + Sources: cli.EnvVars("LOG_FORMAT"), + Action: validateLogFormat, + }, + &cli.StringFlag{ + Name: FlagLogOutput, + Aliases: []string{"o"}, + Value: "stdout", + Usage: "stdout, stderr, file", + Sources: cli.EnvVars("LOG_OUTPUT"), + Action: validateLogOutput, + }, + &cli.StringFlag{ + Name: FlagLogLevel, + Aliases: []string{"l"}, + Value: "info", + Usage: "debug, info, warn, error", + Sources: cli.EnvVars("LOG_LEVEL"), + Action: validateLogLevel, + }, // }}} + // gRPC server {{{ + &cli.IntFlag{ + Name: FlagListenPort, + Aliases: []string{"p"}, + Value: 1234, + Sources: cli.EnvVars("LISTEN_PORT"), + Action: validateListenPort, + }, // }}} + // Database {{{ + &cli.StringFlag{ + Name: FlagDBPath, + Aliases: []string{"d"}, + Value: "store.db", + Usage: "database file", + Sources: cli.EnvVars("DATABASE_PATH"), + Action: validateDBPath, + }, // }}} + } +} + +func validateLogOutput(ctx context.Context, cmd *cli.Command, s string) error { + switch { + case s == "stdout" || s == "stderr": + return nil + default: + // assume file + f, err := os.OpenFile(s, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + slog.ErrorContext( + ctx, + fmt.Sprintf("Error creating/accessing provided log file %s", s), + ) + return err + } + defer f.Close() + return nil + } +} + +func validateLogLevel(ctx context.Context, cmd *cli.Command, s string) error { + for _, lvl := range []string{"deb", "inf", "warn", "err"} { + if strings.Contains(strings.ToLower(s), lvl) { + return nil + } + } + slog.ErrorContext( + ctx, + fmt.Sprintf("Unknown log level provided: %s", s), + ) + return logging.ErrInvalidLevel +} + +func validateLogFormat(ctx context.Context, cmd *cli.Command, s string) error { + s = strings.ToLower(s) + if s == "json" || s == "plain" { + return nil + } + return nil +} + +func validateListenPort(ctx context.Context, cmd *cli.Command, p int64) error { + if p < 1024 || p > 65535 { + slog.ErrorContext( + ctx, + fmt.Sprintf("Out-of-bound port provided: %d", p), + ) + return server.ErrOutOfBoundsPort + } + return nil +} + +func validateDBPath(ctx context.Context, cmd *cli.Command, s string) error { + // TODO: Ensure the db file is writable. + // TODO: Ensure the db file is a valid sqlite3 db. + // TODO: Call db.Reset() if either of the above fail. + // TODO: Log the error/crash if the db file is not writable. + return nil +} diff --git a/internal/logging/context.go b/internal/logging/context.go new file mode 100644 index 0000000..29bad62 --- /dev/null +++ b/internal/logging/context.go @@ -0,0 +1,39 @@ +package logging + +import ( + "context" + "log/slog" +) + +type ctxTracker struct { + ctxKey interface{} + logKey string + next slog.Handler +} + +func (h ctxTracker) Handle(ctx context.Context, r slog.Record) error { + if v := ctx.Value(h.ctxKey); v != nil { + r.AddAttrs(slog.Any(h.logKey, v)) + } + return h.next.Handle(ctx, r) +} + +func (h ctxTracker) Enabled(ctx context.Context, lvl slog.Level) bool { + return h.next.Enabled(ctx, lvl) +} + +func (h ctxTracker) WithAttrs(attrs []slog.Attr) slog.Handler { + return h.next.WithAttrs(attrs) +} + +func (h ctxTracker) WithGroup(name string) slog.Handler { + return h.next.WithGroup(name) +} + +func withTrackedContext(current slog.Handler, ctxKey interface{}, logKey string) *ctxTracker { + return &ctxTracker{ + ctxKey: ctxKey, + logKey: logKey, + next: current, + } +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..91a9734 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,92 @@ +package logging + +import ( + "errors" + "io" + "log/slog" + "os" + "strings" + "time" + + "github.com/ChausseBenjamin/rafta/internal/util" + "github.com/charmbracelet/log" +) + +const ( + ErrKey = "error_message" +) + +var ( + ErrInvalidLevel = errors.New("invalid log level") + ErrInvalidFormat = errors.New("invalid log format") +) + +func Setup(lvlStr, fmtStr, outStr string) error { + output, outputErr := setOutput(outStr) + format, formatErr := setFormat(fmtStr) + level, levelErr := setLevel(lvlStr) + + prefixStr := "" + if format != log.JSONFormatter { + prefixStr = "Rafta 🚢" + } + + var h slog.Handler = log.NewWithOptions( + output, + log.Options{ + TimeFormat: time.DateTime, + Prefix: prefixStr, + Level: level, + ReportCaller: true, + Formatter: format, + }, + ) + + h = withTrackedContext(h, util.ReqIDKey, "request_id") + h = withStackTrace(h) + slog.SetDefault(slog.New(h)) + return errors.Join(outputErr, formatErr, levelErr) +} + +func setLevel(target string) (log.Level, error) { + for _, l := range []struct { + prefix string + level log.Level + }{ + {"deb", log.DebugLevel}, + {"inf", log.InfoLevel}, + {"warn", log.WarnLevel}, + {"err", log.ErrorLevel}, + } { + if strings.HasPrefix(strings.ToLower(target), l.prefix) { + return l.level, nil + } + } + return log.InfoLevel, ErrInvalidLevel +} + +func setFormat(f string) (log.Formatter, error) { + switch f { + case "plain", "text": + return log.TextFormatter, nil + case "json", "structured": + return log.JSONFormatter, nil + } + return log.TextFormatter, ErrInvalidFormat +} + +func setOutput(path string) (io.Writer, error) { + switch path { + case "stdout": + return os.Stdout, nil + case "stderr": + return os.Stderr, nil + default: + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return os.Stdout, err + } else { + return f, nil + } + } +} diff --git a/internal/logging/trace.go b/internal/logging/trace.go new file mode 100644 index 0000000..8900727 --- /dev/null +++ b/internal/logging/trace.go @@ -0,0 +1,61 @@ +package logging + +import ( + "context" + "log/slog" + "runtime" + "strconv" + "strings" +) + +const ( + prgCount = 20 + defSkip = 6 +) + +type stackTracer struct { + h slog.Handler + nSkip int +} + +func (h stackTracer) Enabled(ctx context.Context, lvl slog.Level) bool { + return h.h.Enabled(ctx, lvl) +} + +func (h stackTracer) WithAttrs(attrs []slog.Attr) slog.Handler { + return h.h.WithAttrs(attrs) +} + +func (h stackTracer) WithGroup(name string) slog.Handler { + return h.h.WithGroup(name) +} + +func (h stackTracer) Handle(ctx context.Context, r slog.Record) error { + if r.Level < slog.LevelError { + return h.h.Handle(ctx, r) + } + + trace := h.GetTrace() + r.AddAttrs(slog.String("trace", trace)) + + return h.h.Handle(ctx, r) +} + +func (h stackTracer) GetTrace() string { + var b strings.Builder + pc := make([]uintptr, prgCount) + n := runtime.Callers(h.nSkip, pc) + frames := runtime.CallersFrames(pc[:n]) + + for frame, more := frames.Next(); more; frame, more = frames.Next() { + b.WriteString(frame.Function + "\n " + frame.File + ":" + strconv.Itoa(frame.Line) + "\n") + } + return b.String() +} + +func withStackTrace(h slog.Handler) slog.Handler { + return stackTracer{ + h: h, + nSkip: defSkip, + } +} diff --git a/internal/manualgen/manualgen.go b/internal/manualgen/manualgen.go new file mode 100644 index 0000000..88a72e2 --- /dev/null +++ b/internal/manualgen/manualgen.go @@ -0,0 +1,21 @@ +package main + +import ( + "log/slog" + "os" + + "github.com/ChausseBenjamin/rafta/internal/app" + "github.com/ChausseBenjamin/rafta/internal/logging" + docs "github.com/urfave/cli-docs/v3" +) + +func main() { + a := app.Command() + + man, err := docs.ToManWithSection(a, 1) + if err != nil { + slog.Error("failed to generate markdown", logging.ErrKey, err) + os.Exit(1) + } + os.Stdout.Write([]byte(man)) +} diff --git a/internal/server/model/.gitignore b/internal/server/model/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/internal/server/model/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..70e5861 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,16 @@ +package server + +import ( + "database/sql" + "errors" + + m "github.com/ChausseBenjamin/rafta/internal/server/model" +) + +var ErrOutOfBoundsPort = errors.New("port out of bounds") + +// Implements ComsServer interface +type Service struct { + store *sql.DB + m.UnimplementedRaftaServer +} diff --git a/internal/server/setup.go b/internal/server/setup.go new file mode 100644 index 0000000..551cd95 --- /dev/null +++ b/internal/server/setup.go @@ -0,0 +1,34 @@ +package server + +import ( + "database/sql" + "fmt" + "log/slog" + "net" + + "github.com/ChausseBenjamin/rafta/internal/logging" + m "github.com/ChausseBenjamin/rafta/internal/server/model" + "github.com/ChausseBenjamin/rafta/internal/tagging" + "google.golang.org/grpc" +) + +func Setup(port int64, storage *sql.DB) (*grpc.Server, net.Listener, error) { + lis, err := net.Listen( + "tcp", + fmt.Sprintf(":%d", port), + ) + if err != nil { + slog.Error("Unable to create listener", logging.ErrKey, err) + return nil, nil, err + } + + grpcServer := grpc.NewServer( + grpc.ChainUnaryInterceptor( + tagging.UnaryInterceptor, + ), + ) + raftaService := &Service{store: storage} + m.RegisterRaftaServer(grpcServer, raftaService) + + return grpcServer, lis, nil +} diff --git a/internal/server/task.go b/internal/server/task.go new file mode 100644 index 0000000..b8cf0b8 --- /dev/null +++ b/internal/server/task.go @@ -0,0 +1,33 @@ +package server + +import ( + "context" + "log/slog" + + m "github.com/ChausseBenjamin/rafta/internal/server/model" + "google.golang.org/protobuf/types/known/emptypb" +) + +func (s Service) GetUserTasks(ctx context.Context, id *m.UserID) (*m.TaskList, error) { + slog.ErrorContext(ctx, "GetUserTasks not implemented yet") + return nil, nil +} + +func (s Service) GetTask(ctx context.Context, id *m.TaskID) (*m.Task, error) { + return nil, nil +} + +func (s Service) DeleteTask(ctx context.Context, id *m.TaskID) (*emptypb.Empty, error) { + slog.ErrorContext(ctx, "DeleteTask not implemented yet") + return nil, nil +} + +func (s Service) UpdateTask(ctx context.Context, t *m.Task) (*m.Task, error) { + slog.ErrorContext(ctx, "UpdateTask not implemented yet") + return t, nil +} + +func (s Service) CreateTask(ctx context.Context, data *m.TaskData) (*m.Task, error) { + slog.ErrorContext(ctx, "CreateTask not implemented yet") + return nil, nil +} diff --git a/internal/server/user.go b/internal/server/user.go new file mode 100644 index 0000000..c4a97c4 --- /dev/null +++ b/internal/server/user.go @@ -0,0 +1,34 @@ +package server + +import ( + "context" + "log/slog" + + m "github.com/ChausseBenjamin/rafta/internal/server/model" + "google.golang.org/protobuf/types/known/emptypb" +) + +func (s Service) GetAllUsers(ctx context.Context, empty *emptypb.Empty) (*m.UserList, error) { + slog.ErrorContext(ctx, "GetAllUsers not implemented yet") + return nil, nil +} + +func (s Service) GetUser(ctx context.Context, id *m.UserID) (*m.User, error) { + slog.ErrorContext(ctx, "GetUser not implemented yet") + return nil, nil +} + +func (s Service) DeleteUser(ctx context.Context, id *m.UserID) (*emptypb.Empty, error) { + slog.ErrorContext(ctx, "DeleteUser not implemented yet") + return nil, nil +} + +func (s Service) UpdateUser(ctx context.Context, u *m.User) (*m.User, error) { + slog.ErrorContext(ctx, "UpdateUser not implemented yet") + return nil, nil +} + +func (s Service) CreateUser(ctx context.Context, data *m.UserData) (*m.User, error) { + slog.ErrorContext(ctx, "CreateUser not implemented yet") + return nil, nil +} diff --git a/internal/storage/db.go b/internal/storage/db.go new file mode 100644 index 0000000..06fe31c --- /dev/null +++ b/internal/storage/db.go @@ -0,0 +1,18 @@ +package storage + +import ( + "context" + "database/sql" + "log/slog" + + "github.com/ChausseBenjamin/rafta/internal/util" +) + +func GetDB(ctx context.Context) *sql.DB { + db, ok := ctx.Value(util.DBKey).(*sql.DB) + if !ok { + slog.Error("Unable to retrieve database from context") + return nil + } + return db +} diff --git a/internal/storage/schema.go b/internal/storage/schema.go new file mode 100644 index 0000000..042c09b --- /dev/null +++ b/internal/storage/schema.go @@ -0,0 +1,38 @@ +package storage + +func opts() string { + return "?_foreign_keys=on&_journal_mode=WAL" +} + +func schema() string { + return ` +CREATE TABLE User ( + UserID INTEGER PRIMARY KEY AUTOINCREMENT, + Name TEXT NOT NULL, + Email TEXT NOT NULL UNIQUE +); + +CREATE TABLE Task ( + TaskID INTEGER PRIMARY KEY AUTOINCREMENT, + Title TEXT NOT NULL, + Description TEXT NOT NULL, + Due DATE NOT NULL, + Do DATE NOT NULL, + Owner INTEGER NOT NULL, + FOREIGN KEY (Owner) REFERENCES User(UserID) ON DELETE CASCADE +); + +CREATE TABLE Tag ( + TagID INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + Name TEXT NOT NULL UNIQUE +); + +CREATE TABLE TaskTag ( + TaskUUID INTEGER NOT NULL, + TagID INTEGER NOT NULL, + PRIMARY KEY (TaskUUID, TagID), + FOREIGN KEY (TaskUUID) REFERENCES Task(UUID) ON DELETE CASCADE, + FOREIGN KEY (TagID) REFERENCES Tag(TagID) ON DELETE CASCADE +); +` +} diff --git a/internal/storage/setup.go b/internal/storage/setup.go new file mode 100644 index 0000000..f103b15 --- /dev/null +++ b/internal/storage/setup.go @@ -0,0 +1,201 @@ +package storage + +import ( + "database/sql" + "errors" + "fmt" + "log/slog" + "os" + "strings" + + _ "github.com/mattn/go-sqlite3" +) + +var ( + ErrIntegrityCheckFailed = errors.New("integrity check failed") + ErrForeignKeysDisabled = errors.New("foreign_keys pragma is not enabled") + ErrJournalModeInvalid = errors.New("journal_mode is not wal") + ErrSchemaMismatch = errors.New("schema does not match expected definition") +) + +// Setup opens the SQLite DB at path, verifies its integrity and schema, +// and returns the valid DB handle. On any error, it backs up the old file +// (if it exists) and calls genDB() to initialize a valid schema. +func Setup(path string) (*sql.DB, error) { + _, statErr := os.Stat(path) + exists := statErr == nil + + // If file doesn't exist, generate new DB. + if !exists { + return genDB(path) + } + + db, err := sql.Open("sqlite3", path+opts()) + if err != nil { + slog.Error("failed to open DB", "error", err) + backupFile(path) + return genDB(path) + } + + // Integrity check. + var integrity string + if err = db.QueryRow("PRAGMA integrity_check;").Scan(&integrity); err != nil { + slog.Error("integrity check query failed", "error", err) + db.Close() + backupFile(path) + return genDB(path) + } + if integrity != "ok" { + slog.Error("integrity check failed", "error", ErrIntegrityCheckFailed) + db.Close() + backupFile(path) + return genDB(path) + } + + // Validate schema and pragmas. + if err = validateSchema(db); err != nil { + slog.Error("schema validation failed", "error", err) + db.Close() + backupFile(path) + return genDB(path) + } + + return db, nil +} + +// validateSchema verifies that required pragmas and table definitions are set. +func validateSchema(db *sql.DB) error { + // Check PRAGMA foreign_keys = on. + var fk int + if err := db.QueryRow("PRAGMA foreign_keys;").Scan(&fk); err != nil { + return err + } + if fk != 1 { + return ErrForeignKeysDisabled + } + + // Check PRAGMA journal_mode = wal. + var jm string + if err := db.QueryRow("PRAGMA journal_mode;").Scan(&jm); err != nil { + return err + } + if strings.ToLower(jm) != "wal" { + return ErrJournalModeInvalid + } + + // Define required table definitions (as substrings in lower-case). + type tableCheck struct { + name string + substrings []string + } + + checks := []tableCheck{ + { + name: "User", + substrings: []string{ + "create table user", + "userid", "integer", "primary key", "autoincrement", + "name", "text", "not null", + "email", "text", "not null", "unique", + }, + }, + { + name: "Task", + substrings: []string{ + "create table task", + "taskid", "integer", "primary key", "autoincrement", + "title", "not null", + "description", "not null", + "due", "date", "not null", + "do", "date", "not null", + "owner", "integer", "not null", + "foreign key", "references user", + }, + }, + { + name: "Tag", + substrings: []string{ + "create table tag", + "tagid", "integer", "primary key", "autoincrement", + "name", "text", "not null", "unique", + }, + }, + { + name: "TaskTag", + substrings: []string{ + "create table tasktag", + "taskuuid", "integer", "not null", + "tagid", "integer", "not null", + "primary key", + "foreign key", "references task", + "foreign key", "references tag", + }, + }, + } + + for _, chk := range checks { + sqlDef, err := fetchTableSQL(db, chk.name) + if err != nil { + return fmt.Errorf("failed to fetch definition for table %s: %w", chk.name, err) + } + lc := strings.ToLower(sqlDef) + for _, substr := range chk.substrings { + if !strings.Contains(lc, substr) { + return fmt.Errorf("%w: table %s missing %q", ErrSchemaMismatch, chk.name, substr) + } + } + } + + return nil +} + +func fetchTableSQL(db *sql.DB, table string) (string, error) { + var sqlDef sql.NullString + err := db.QueryRow("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", table).Scan(&sqlDef) + if err != nil { + return "", err + } + if !sqlDef.Valid { + return "", fmt.Errorf("no SQL definition found for table %s", table) + } + return sqlDef.String, nil +} + +// backupFile renames the existing file by appending a ".bak" suffix. +func backupFile(path string) { + backupPath := path + ".bak" + // If backupPath exists, append a timestamp. + if _, err := os.Stat(backupPath); err == nil { + backupPath = fmt.Sprintf("%s.%d.bak", path, os.Getpid()) + } + if err := os.Rename(path, backupPath); err != nil { + slog.Error("failed to backup file", "error", err, "original", path, "backup", backupPath) + } else { + slog.Info("backed up corrupt DB", "original", path, "backup", backupPath) + } +} + +// genDB creates a new database at path with the valid schema. +func genDB(path string) (*sql.DB, error) { + db, err := sql.Open("sqlite3", path) + if err != nil { + slog.Error("failed to create DB", "error", err) + return nil, err + } + + // Set pragmas. + if _, err := db.Exec("PRAGMA foreign_keys = on; PRAGMA journal_mode = wal;"); err != nil { + slog.Error("failed to set pragmas", "error", err) + db.Close() + return nil, err + } + + if _, err := db.Exec(schema()); err != nil { + slog.Error("failed to initialize schema", "error", err) + db.Close() + return nil, err + } + + slog.Info("created new blank DB with valid schema", "path", path) + return db, nil +} diff --git a/internal/tagging/tagging.go b/internal/tagging/tagging.go new file mode 100644 index 0000000..8e8efe9 --- /dev/null +++ b/internal/tagging/tagging.go @@ -0,0 +1,23 @@ +package tagging + +import ( + "context" + "log/slog" + + "github.com/ChausseBenjamin/rafta/internal/logging" + "github.com/ChausseBenjamin/rafta/internal/util" + "github.com/hashicorp/go-uuid" + "google.golang.org/grpc" +) + +// gRPC interceptor to tag requests with a unique identifier +func UnaryInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { + id, err := uuid.GenerateUUID() + if err != nil { + slog.Error("Unable to generate UUID for request", logging.ErrKey, err) + } + ctx = context.WithValue(ctx, util.ReqIDKey, id) + slog.DebugContext(ctx, "Tagging request with UUID", "value", id) + + return handler(ctx, req) +} diff --git a/internal/util/context.go b/internal/util/context.go new file mode 100644 index 0000000..e7cbab2 --- /dev/null +++ b/internal/util/context.go @@ -0,0 +1,8 @@ +package util + +type ContextKey uint8 + +const ( + DBKey ContextKey = iota + ReqIDKey +) @@ -0,0 +1,19 @@ +package main + +import ( + "context" + "log/slog" + "os" + + "github.com/ChausseBenjamin/rafta/internal/app" + "github.com/ChausseBenjamin/rafta/internal/logging" +) + +func main() { + cmd := app.Command() + + if err := cmd.Run(context.Background(), os.Args); err != nil { + slog.Error("Program quit unexpectedly", logging.ErrKey, err) + os.Exit(1) + } +} diff --git a/resources/local_dev.sh b/resources/local_dev.sh new file mode 100755 index 0000000..68bb46b --- /dev/null +++ b/resources/local_dev.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +env_vars=$(cat << EOF + LOG_LEVEL=debug + LOG_FORMAT=plain + LOG_OUTPUT=stdout + LISTEN_PORT=1234 + DATABASE_PATH=runtime/store.db +EOF +) + +case "$1" in + --get-config) + echo "$env_vars" + ;; + *) + clear && env $env_vars go run . $@ + ;; +esac diff --git a/resources/schema.proto b/resources/schema.proto new file mode 100644 index 0000000..5b34332 --- /dev/null +++ b/resources/schema.proto @@ -0,0 +1,79 @@ +syntax = "proto3"; + +import "protobuf/src/google/protobuf/empty.proto"; +import "protobuf/src/google/protobuf/timestamp.proto"; + +option go_package = "github.com/ChausseBenjamin/rafta/internal/server/model"; + +enum TaskState { + TASK_UNDEFINED = 0; + TASK_PENDING = 1; + TASK_IN_PROGRESS = 2; + TASK_DONE = 3; + TASK_BLOCKED = 4; +} + +message UserID { + string uuid = 1; +} + +message UserData { + string name = 1; + string email = 2; + google.protobuf.Timestamp created_on = 3; + google.protobuf.Timestamp last_login = 4; +} + +message User { + UserID id = 1; + UserData data = 2; +} + +message TaskID { + string uuid = 1; +} + +message TaskData { + string title = 1; + Description desc = 2; // markdown + uint32 priority = 3; + TaskState state = 4; + google.protobuf.Timestamp created_on = 5; + google.protobuf.Timestamp last_updated = 6; + repeated string tags = 7; +} + +message Description { + string data = 1; +} + +message Task { + TaskID id = 1; + TaskData data = 2; +} + +message TaskList { + repeated Task tasks = 1; +} + +message UserList { + repeated User users = 1; +} + +service Rafta { + // Retrieval + rpc GetUserTasks(UserID) returns (TaskList); + rpc GetAllUsers(google.protobuf.Empty) returns (UserList); + rpc GetUser(UserID) returns (User); + rpc GetTask(TaskID) returns (Task); + + // Task Manipulation + rpc DeleteTask(TaskID) returns (google.protobuf.Empty); + rpc UpdateTask(Task) returns (Task); + rpc CreateTask(TaskData) returns (Task); + + // User Manipulation + rpc DeleteUser(UserID) returns (google.protobuf.Empty); + rpc UpdateUser(User) returns (User); + rpc CreateUser(UserData) returns (User); +} diff --git a/runtime/.gitignore b/runtime/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/runtime/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore |