From 8b850f83eeac4050a24f1462319de28c1e60471c Mon Sep 17 00:00:00 2001 From: Brett Bender Date: Thu, 18 Jan 2024 00:02:55 -0600 Subject: [PATCH] initial commit --- .gitignore | 117 ++++++++++++++++++++++++++++++ cmd/server/server.go | 68 ++++++++++++++++++ go.mod | 20 ++++++ go.sum | 38 ++++++++++ internal/api/api.go | 77 ++++++++++++++++++++ internal/api/assets.go | 124 ++++++++++++++++++++++++++++++++ internal/api/shelves.go | 96 +++++++++++++++++++++++++ internal/api/types.go | 52 ++++++++++++++ internal/storage/datastore.go | 131 ++++++++++++++++++++++++++++++++++ internal/types/api.go | 44 ++++++++++++ internal/types/assets.go | 63 ++++++++++++++++ internal/types/buildings.go | 28 ++++++++ internal/types/categories.go | 28 ++++++++ internal/types/shelves.go | 43 +++++++++++ magefile.go | 23 ++++++ 15 files changed, 952 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/server/server.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/api/api.go create mode 100644 internal/api/assets.go create mode 100644 internal/api/shelves.go create mode 100644 internal/api/types.go create mode 100644 internal/storage/datastore.go create mode 100644 internal/types/api.go create mode 100644 internal/types/assets.go create mode 100644 internal/types/buildings.go create mode 100644 internal/types/categories.go create mode 100644 internal/types/shelves.go create mode 100644 magefile.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7239fd5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,117 @@ +# Created by https://www.toptal.com/developers/gitignore/api/goland+all,go +# Edit at https://www.toptal.com/developers/gitignore?templates=goland+all,go + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### GoLand+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### GoLand+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +# End of https://www.toptal.com/developers/gitignore/api/goland+all,go + +build \ No newline at end of file diff --git a/cmd/server/server.go b/cmd/server/server.go new file mode 100644 index 0000000..66f5879 --- /dev/null +++ b/cmd/server/server.go @@ -0,0 +1,68 @@ +package main + +import ( + "flag" + "log" + + "git.brettb.xyz/goinv/server/internal/api" + "git.brettb.xyz/goinv/server/internal/storage" + "git.brettb.xyz/goinv/server/internal/types" +) + +func main() { + seed := flag.Bool("seed", false, "seed the database") + flag.Parse() + + datastore, err := storage.NewDataStorePG("127.0.0.1", "postgres", "password", "em_test", "disable") // TODO: CONFIGURATION + if err != nil { + panic(err) + } + + if *seed { + log.Println("Seeding database") + cat := types.Category{ + Name: "House", + } + + if err := datastore.CreateCategory(&cat); err != nil { + panic(err) + } + + building := types.Building{ + Name: "Memorial Student Center", + } + + if err := datastore.CreateBuilding(&building); err != nil { + panic(err) + } + + shelf := types.ShelfLocation{ + Name: "SHELF-TEST", + BuildingID: building.ID, + } + + if err := datastore.CreateShelfLocation(&shelf); err != nil { + panic(err) + } + + if err := datastore.CreateAsset(&types.Asset{ + Name: "Test", + Quantity: 1, + Length: "6 in", + Manufacturer: "Testing", + ModelName: "Test", + Price: 420.69, + Comments: "", + ShelfLocationID: &shelf.ID, + CategoryID: &cat.ID, + }); err != nil { + panic(err) + } + + return + } + + s := api.NewAPIServer(datastore, ":3001") + + s.Run() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2b215e1 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module git.brettb.xyz/goinv/server + +go 1.21.6 + +require gorm.io/driver/postgres v1.5.4 + +require github.com/ajg/form v1.5.1 // indirect + +require ( + github.com/go-chi/chi/v5 v5.0.11 + github.com/go-chi/render v1.0.3 + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/pgx/v5 v5.4.3 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/text v0.13.0 // indirect + gorm.io/gorm v1.25.5 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..05da437 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= +github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= +github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= +github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= +github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo= +gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0= +gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= +gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..d568c6e --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,77 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + + "git.brettb.xyz/goinv/server/internal/storage" + "git.brettb.xyz/goinv/server/internal/types" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/render" +) + +const API_VERSION_MAJOR = 0 +const API_VERSION_MINOR = 0 +const API_VERSION_PATCH = 1 + +var API_VERSION = types.APIVersion{ + Major: API_VERSION_MAJOR, + Minor: API_VERSION_MINOR, + Patch: API_VERSION_PATCH, +} + +type APIFunc func(w http.ResponseWriter, r *http.Request) error + +type APIServer struct { + listenAddr string + db *storage.DataStore +} + +func NewAPIServer(database *storage.DataStore, listenAddr string) *APIServer { + return &APIServer{ + listenAddr: listenAddr, + db: database, + } +} + +func (s *APIServer) Run() { + r := chi.NewRouter() + + r.Use(middleware.Logger) + r.Use(render.SetContentType(render.ContentTypeJSON)) + + s.registerRoutes(r) + + log.Printf("API Server listening on %s", s.listenAddr) + + err := http.ListenAndServe(s.listenAddr, r) + if err != nil { + panic(err) + } +} + +func (s *APIServer) registerRoutes(r *chi.Mux) { + r.Get("/", makeHandler(s.handleIndex)) + + r.Route("/assets", s.setupAssetRoutes()) + r.Route("/shelves", s.setupShelfRoutes()) +} + +func (s *APIServer) handleIndex(w http.ResponseWriter, r *http.Request) error { + return writeJSON(w, http.StatusOK, types.IndexResponse{Version: API_VERSION}) +} + +func makeHandler(f APIFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := f(w, r); err != nil { + render.Render(w, r, errBadRequest(err)) + } + } +} + +func writeJSON(w http.ResponseWriter, s int, v any) error { + w.WriteHeader(s) + return json.NewEncoder(w).Encode(v) +} diff --git a/internal/api/assets.go b/internal/api/assets.go new file mode 100644 index 0000000..03a1b37 --- /dev/null +++ b/internal/api/assets.go @@ -0,0 +1,124 @@ +package api + +import ( + "context" + "log" + "net/http" + "strconv" + + "git.brettb.xyz/goinv/server/internal/types" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func (s *APIServer) setupAssetRoutes() func(r chi.Router) { + return func(r chi.Router) { + r.Get("/", makeHandler(s.getAssets)) + r.Post("/", makeHandler(s.createAsset)) + + r.Route("/{assetID}", func(r chi.Router) { + r.Use(s.AssetCtx) + r.Get("/", makeHandler(s.getAsset)) + r.Delete("/", makeHandler(s.deleteAsset)) + }) + } +} + +func (s *APIServer) AssetCtx(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assetIdStr := chi.URLParam(r, "assetID") + assetId, err := strconv.Atoi(assetIdStr) + if err != nil { + render.Render(w, r, errNotFound) + return + } + asset, err := s.db.GetAssetByID(uint64(assetId)) + if err != nil { + render.Render(w, r, errNotFound) + return + } + ctx := context.WithValue(r.Context(), "asset", asset) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (s *APIServer) getAssets(w http.ResponseWriter, r *http.Request) error { + assets, err := s.db.GetAssets(0, 50) // TODO: Proper Pagination + if err != nil { + return err + } + + total, err := s.db.TotalAssets() + if err != nil { + return err + } + + return render.Render(w, r, &types.MultipleAssetsResponse{ + Response: &types.Response{ + HTTPStatusCode: http.StatusOK, + }, + Assets: assets, + Total: total, + }) +} + +func (s *APIServer) getAsset(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + asset, ok := ctx.Value("asset").(*types.Asset) + if !ok { + return render.Render(w, r, errUnprocessable) + } + + return render.Render(w, r, &types.AssetResponse{ + Response: &types.Response{ + HTTPStatusCode: http.StatusOK, + }, + Asset: asset, + }) +} + +func (s *APIServer) deleteAsset(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + asset, ok := ctx.Value("asset").(*types.Asset) + if !ok { + return render.Render(w, r, errUnprocessable) + } + + if ok, _ := s.db.DeleteAssetByID(asset.ID); !ok { + return render.Render(w, r, errUnprocessable) + } + + return render.Render(w, r, &types.AssetResponse{Response: &types.Response{HTTPStatusCode: http.StatusOK}, Asset: asset}) +} + +func (s *APIServer) createAsset(w http.ResponseWriter, r *http.Request) error { + data := &types.CreateAssetRequest{} + if err := render.Bind(r, data); err != nil { + log.Printf("ERR: %v\n", err) + return render.Render(w, r, errBadRequest(err)) + } + + asset := &types.Asset{ + Name: data.Name, + Quantity: data.Quantity, + Length: data.Length, + Manufacturer: data.Manufacturer, + ModelName: data.ModelName, + Price: data.Price, + Comments: data.Comments, + ShelfLocationID: data.ShelfLocationID, + CategoryID: data.CategoryID, + } + + err := s.db.CreateAsset(asset) + if err != nil { + return err + } + + return render.Render(w, r, &types.AssetResponse{ + Response: &types.Response{ + HTTPStatusCode: http.StatusOK, + }, + Asset: asset, + }) +} diff --git a/internal/api/shelves.go b/internal/api/shelves.go new file mode 100644 index 0000000..7daba74 --- /dev/null +++ b/internal/api/shelves.go @@ -0,0 +1,96 @@ +package api + +import ( + "context" + "net/http" + "strconv" + + "git.brettb.xyz/goinv/server/internal/types" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +func (s *APIServer) setupShelfRoutes() func(chi.Router) { + return func(r chi.Router) { + r.Get("/", makeHandler(s.getShelves)) + //r.Post("/", makeHandler(s.createShelf)) + + r.Route("/{shelfID}", func(r chi.Router) { + r.Use(s.ShelfCtx) + r.Get("/", makeHandler(s.getShelf)) + r.Delete("/", makeHandler(s.deleteShelf)) + }) + } +} + +func (s *APIServer) ShelfCtx(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + shelfIdStr := chi.URLParam(r, "shelfID") + shelfId, err := strconv.ParseUint(shelfIdStr, 10, 64) + if err != nil { + render.Render(w, r, errNotFound) + return + } + shelf, err := s.db.GetShelfByID(shelfId) + if err != nil { + render.Render(w, r, errNotFound) + return + } + ctx := context.WithValue(r.Context(), "shelf", shelf) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (s *APIServer) getShelves(w http.ResponseWriter, r *http.Request) error { + shelves, err := s.db.GetShelves(0, 10) + if err != nil { + return err + } + + total, err := s.db.TotalShelves() + if err != nil { + return err + } + + return render.Render(w, r, &types.MultipleShelfResponse{ + Response: &types.Response{ + HTTPStatusCode: http.StatusOK, + }, + ShelfLocations: shelves, + Total: total, + }) +} + +func (s *APIServer) getShelf(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + shelf, ok := ctx.Value("shelf").(*types.ShelfLocation) + if !ok { + return render.Render(w, r, errUnprocessable) + } + + return render.Render(w, r, &types.ShelfResponse{ + Response: &types.Response{ + HTTPStatusCode: http.StatusOK, + }, + ShelfLocation: shelf, + }) +} + +func (s *APIServer) deleteShelf(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + shelf, ok := ctx.Value("shelf").(*types.ShelfLocation) + if !ok { + return render.Render(w, r, errUnprocessable) + } + + if ok, _ := s.db.DeleteShelfByID(shelf.ID); !ok { + return render.Render(w, r, errUnprocessable) + } + + return render.Render(w, r, &types.ShelfResponse{ + Response: &types.Response{ + HTTPStatusCode: http.StatusOK, + }, + ShelfLocation: shelf, + }) +} diff --git a/internal/api/types.go b/internal/api/types.go new file mode 100644 index 0000000..815e827 --- /dev/null +++ b/internal/api/types.go @@ -0,0 +1,52 @@ +package api + +import ( + "net/http" + + "git.brettb.xyz/goinv/server/internal/types" + "github.com/go-chi/render" +) + +var errNotFound = &types.APIError{ + Response: &types.Response{ + HTTPStatusCode: http.StatusNotFound, + }, + Messages: []string{"resource not found"}, +} + +var errUnprocessable = &types.APIError{ + Response: &types.Response{ + HTTPStatusCode: http.StatusUnprocessableEntity, + }, + Messages: []string{"unable to process"}, +} + +func errBadRequest(err error) render.Renderer { + return &types.APIError{ + Response: &types.Response{ + HTTPStatusCode: http.StatusBadRequest, + }, + Err: err, + Messages: []string{"bad request"}, + } +} + +func errRender(err error) render.Renderer { + return &types.APIError{ + Response: &types.Response{ + HTTPStatusCode: http.StatusUnprocessableEntity, + }, + Err: err, + Messages: []string{"error rendering response"}, + } +} + +func errUnauthorized(err error) render.Renderer { + return &types.APIError{ + Response: &types.Response{ + HTTPStatusCode: http.StatusUnauthorized, + }, + Err: err, + Messages: []string{"unauthorized"}, + } +} diff --git a/internal/storage/datastore.go b/internal/storage/datastore.go new file mode 100644 index 0000000..113337e --- /dev/null +++ b/internal/storage/datastore.go @@ -0,0 +1,131 @@ +package storage + +import ( + "fmt" + "log" + + "git.brettb.xyz/goinv/server/internal/types" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +type DataStore struct { + db *gorm.DB +} + +func NewDataStorePG(host, user, password, dbname, sslmode string) (*DataStore, error) { + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s sslmode=%s", host, user, password, dbname, sslmode) + + log.Printf("Connecting to %s@%s/%s\n", host, user, dbname) + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + return nil, err + } + + log.Printf("Auto-Migrating models") + + if err := db.AutoMigrate( + &types.Asset{}, + &types.Building{}, + &types.Category{}, + &types.ShelfLocation{}, + ); err != nil { + return nil, err + } + + return &DataStore{ + db: db, + }, nil +} + +func (s *DataStore) CreateAsset(asset *types.Asset) error { + result := s.db.Create(asset) + return result.Error +} + +func (s *DataStore) CreateBuilding(building *types.Building) error { + result := s.db.Create(building) + return result.Error +} + +func (s *DataStore) CreateCategory(category *types.Category) error { + result := s.db.Create(category) + return result.Error +} + +func (s *DataStore) CreateShelfLocation(shelf *types.ShelfLocation) error { + result := s.db.Create(shelf) + return result.Error +} + +func (s *DataStore) GetAssetByID(id uint64) (*types.Asset, error) { + var result types.Asset + tx := s.db.Model(&types.Asset{}).Where("id = ?", id).First(&result) + if tx.Error != nil { + return nil, fmt.Errorf("asset %d not found", id) + } + return &result, nil +} + +func (s *DataStore) GetAssets(offset, limit uint64) ([]*types.Asset, error) { + var assets []*types.Asset + s.db.Joins("Category").Joins("ShelfLocation").Order("id asc").Offset(int(offset)).Limit(int(limit)).Find(&assets) + if len(assets) == 0 { + return nil, fmt.Errorf("no assets found") + } + return assets, nil +} + +func (s *DataStore) TotalAssets() (int64, error) { + var count int64 + if tx := s.db.Find(&types.Asset{}).Count(&count); tx.Error != nil { + return 0, tx.Error + } + return count, nil +} + +func (s *DataStore) DeleteAssetByID(id uint64) (bool, error) { + tx := s.db.Delete(&types.Asset{}, id) + if tx.Error != nil { + return false, fmt.Errorf("unable to delete: %s", tx.Error.Error()) + } + return true, nil +} + +func (s *DataStore) GetShelfByID(id uint64) (*types.ShelfLocation, error) { + var result types.ShelfLocation + tx := s.db.Model(&types.ShelfLocation{}).Where("id = ?", id).First(&result) + if tx.Error != nil { + return nil, fmt.Errorf("shelf %d not found", id) + } + return &result, nil +} + +func (s *DataStore) GetShelves(offset, limit int) ([]*types.ShelfLocation, error) { + var shelves []*types.ShelfLocation + s.db.Offset(offset).Limit(limit).Find(&shelves) + if len(shelves) == 0 { + return nil, fmt.Errorf("no shelves found") + } + return shelves, nil +} + +func (s *DataStore) TotalShelves() (int64, error) { + var count int64 + if tx := s.db.Find(&types.ShelfLocation{}).Count(&count); tx.Error != nil { + return 0, tx.Error + } + return count, nil +} + +func (s *DataStore) DeleteShelfByID(id uint64) (bool, error) { + tx := s.db.Delete(&types.ShelfLocation{}, id) + if tx.Error != nil { + return false, fmt.Errorf("unable to delete: %s", tx.Error.Error()) + } + return true, nil +} diff --git a/internal/types/api.go b/internal/types/api.go new file mode 100644 index 0000000..4ea8d73 --- /dev/null +++ b/internal/types/api.go @@ -0,0 +1,44 @@ +package types + +import ( + "net/http" + + "github.com/go-chi/render" +) + +type Response struct { + HTTPStatusCode int `json:"status"` +} + +func (r *Response) Render(w http.ResponseWriter, req *http.Request) error { + render.Status(req, r.HTTPStatusCode) + return nil +} + +type APIVersion struct { + Major int `json:"major"` + Minor int `json:"minor"` + Patch int `json:"patch"` +} + +type IndexResponse struct { + *Response + Version APIVersion `json:"version"` +} + +type APIError struct { + *Response + Err error `json:"-"` + + Messages []string `json:"messages"` +} + +func NewAPIError(status int, messages ...string) *APIError { + return &APIError{ + Response: &Response{ + HTTPStatusCode: status, + }, + Err: nil, + Messages: messages, + } +} diff --git a/internal/types/assets.go b/internal/types/assets.go new file mode 100644 index 0000000..57ef480 --- /dev/null +++ b/internal/types/assets.go @@ -0,0 +1,63 @@ +package types + +import ( + "net/http" + "time" + + "gorm.io/gorm" +) + +/* + Base Model +*/ + +type Asset struct { + ID uint64 `gorm:"primarykey" json:"id"` + Name string `json:"name"` + Quantity int `json:"quantity"` + Length string `json:"length,omitempty"` + Manufacturer string `json:"manufacturer,omitempty"` + ModelName string `json:"model_name,omitempty"` + Price float64 `json:"price,omitempty"` + Comments string `json:"comments,omitempty"` + ShelfLocationID *uint64 `json:"-"` + ShelfLocation *ShelfLocation `json:"shelf_location,omitempty"` + CategoryID *uint64 `json:"-"` + Category *Category `json:"category,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` +} + +/* + Requests +*/ + +type CreateAssetRequest struct { + Name string `json:"name"` + Quantity int `json:"quantity"` + Length string `json:"length,omitempty"` + Manufacturer string `json:"manufacturer,omitempty"` + ModelName string `json:"model_name,omitempty"` + Price float64 `json:"price,omitempty"` + Comments string `json:"comments,omitempty"` + ShelfLocationID *uint64 `json:"shelf_location_id,omitempty"` + CategoryID *uint64 `json:"category_id,omitempty"` +} + +func (c CreateAssetRequest) Bind(r *http.Request) error { return nil } + +/* + Responses +*/ + +type AssetResponse struct { + *Response + Asset *Asset `json:"asset"` +} + +type MultipleAssetsResponse struct { + *Response + Assets []*Asset `json:"assets"` + Total int64 `json:"total"` +} diff --git a/internal/types/buildings.go b/internal/types/buildings.go new file mode 100644 index 0000000..8754fc9 --- /dev/null +++ b/internal/types/buildings.go @@ -0,0 +1,28 @@ +package types + +import ( + "time" + + "gorm.io/gorm" +) + +/* + Base Model +*/ + +type Building struct { + ID uint64 `gorm:"primarykey" json:"id"` + Name string `json:"name"` + ShelfLocations []ShelfLocation `json:"shelves"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` +} + +/* + Requests +*/ + +/* + Responses +*/ diff --git a/internal/types/categories.go b/internal/types/categories.go new file mode 100644 index 0000000..270650b --- /dev/null +++ b/internal/types/categories.go @@ -0,0 +1,28 @@ +package types + +import ( + "time" + + "gorm.io/gorm" +) + +/* + Base Model +*/ + +type Category struct { + ID uint64 `gorm:"primarykey" json:"id"` + Name string `json:"name"` + Assets []Asset `json:"assets,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` +} + +/* + Requests +*/ + +/* + Responses +*/ diff --git a/internal/types/shelves.go b/internal/types/shelves.go new file mode 100644 index 0000000..1fabb4e --- /dev/null +++ b/internal/types/shelves.go @@ -0,0 +1,43 @@ +package types + +import ( + "time" + + "gorm.io/gorm" +) + +/* + Base Model +*/ + +type ShelfLocation struct { + ID uint64 `gorm:"primarykey" json:"id"` + Name string `json:"name"` + RoomNumber string `json:"room_number,omitempty"` + Description string `json:"description,omitempty"` + BuildingID uint64 `json:"-"` + Building Building `json:"building"` + Assets []Asset `json:"assets,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` +} + +/* + Requests +*/ + +/* + Responses +*/ + +type ShelfResponse struct { + *Response + ShelfLocation *ShelfLocation `json:"shelf"` +} + +type MultipleShelfResponse struct { + *Response + ShelfLocations []*ShelfLocation `json:"shelves"` + Total int64 `json:"total"` +} diff --git a/magefile.go b/magefile.go new file mode 100644 index 0000000..dfc96f8 --- /dev/null +++ b/magefile.go @@ -0,0 +1,23 @@ +//go:build mage +// +build mage + +package main + +import ( + "github.com/magefile/mage/mg" + "github.com/magefile/mage/sh" +) + +var Default = Build + +func Build() error { + if err := sh.Run("go", "mod", "download"); err != nil { + return err + } + return sh.Run("go", "build", "-o", "./build/goinv-server", "./cmd/server") +} + +func Run() error { + mg.Deps(Build) + return sh.RunV("./build/goinv-server") +}