diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index ed8bc4b..568e03d 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -7,6 +7,7 @@ on: - develop - 'feature/*' - 'releases/*' + - 'v0.1.0-20240104' jobs: test-packages: runs-on: ubuntu-latest @@ -39,50 +40,7 @@ jobs: run: apk add --update-cache git - name: Init Database run: go run test/cmd/initdb/main.go + - name: Go mod tidy + run: go mod tidy - name: Run tests run: go test -v ./pkg/... - run-examples: - runs-on: ubuntu-latest - container: - image: golang:1.22-alpine3.20 - defaults: - run: - shell: sh - env: - ENV: test - DATABASE_URL: postgres://gooo:password@db:5432/gooo_test?sslmode=disable - services: - db: - image: postgres:16.2 - env: - POSTGRES_USER: gooo - POSTGRES_PASSWORD: password - POSTGRES_DB: gooo_test - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - steps: - - name: Check out repository code - uses: actions/checkout@v4 - - name: Install dependencies - run: apk add --update-cache git - - name: Init Database - run: go run test/cmd/initdb/main.go - # - name: Run API - # run: go run examples/starter/cmd/api/main.go - - name: Run Scheme - run: go run examples/starter/cmd/schema/main.go examples/starter/models - - name: Run Seed - run: go run examples/starter/cmd/seed/main.go - - name: Run Migration Up - env: - MIGRATION_PATH: examples/starter/db/migrations/*.sql - run: go run examples/starter/cmd/migration/main.go up - - name: Run Migration Down - env: - MIGRATION_PATH: examples/starter/db/migrations/*.sql - run: go run examples/starter/cmd/migration/main.go down diff --git a/README.md b/README.md index de8ac60..fd63c6a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Go Full Stack Framework ### Features - App - - HHTTP Server + - HTTP Server - Controller - Middleware - CORS diff --git a/examples/bare/cmd/app.go b/examples/bare/cmd/app.go new file mode 100644 index 0000000..f5b6229 --- /dev/null +++ b/examples/bare/cmd/app.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "log" + "net/http" + "time" + + "github.com/version-1/gooo/examples/bare/internal/swagger" + "github.com/version-1/gooo/pkg/core/api/app" + "github.com/version-1/gooo/pkg/core/api/request" + "github.com/version-1/gooo/pkg/core/api/response" + "github.com/version-1/gooo/pkg/core/api/route" + "github.com/version-1/gooo/pkg/toolkit/logger" + "github.com/version-1/gooo/pkg/toolkit/middleware" +) + +type User struct { + ID string `json:"id"` + Username string `json:"name"` + Email string `json:"email"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +type UserCreate struct { + Username string `json:"name"` + Email string `json:"email"` +} + +func main() { + cfg := &app.Config{} + cfg.SetLogger(logger.DefaultLogger) + + server := &app.App{ + Addr: ":8080", + Config: cfg, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + cfg.Logger().Errorf("Error: %+v", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal server error")) + }, + } + + users := route.GroupHandler{ + Path: "/users", + Handlers: []route.HandlerInterface{ + route.JSON[request.Void, map[string]string]().Get("", func(res *response.Response[map[string]string], req *request.Request[request.Void]) { + res.Render(map[string]string{"message": "ok"}) + }), + route.JSON[UserCreate, User]().Post("", func(res *response.Response[User], req *request.Request[UserCreate]) { + body, err := req.Body() + if err != nil { + res.BadRequest(err) + return + } + + now := time.Now() + user := User{ + ID: "1", + Username: body.Username, + Email: body.Email, + Created: now, + Updated: now, + } + res.Render(user) + }), + route.JSON[request.Void, any]().Get(":id", func(res *response.Response[any], req *request.Request[request.Void]) { + res.Render(map[string]string{"message": "ok"}) + }), + route.JSON[request.Void, any]().Patch(":id", func(res *response.Response[any], req *request.Request[request.Void]) { + res.Render(map[string]string{"message": "ok"}) + }), + route.JSON[request.Void, any]().Delete(":id", func(res *response.Response[any], req *request.Request[request.Void]) { + res.Render(map[string]string{"message": "ok"}) + }), + }, + } + swagger := route.GroupHandler{ + Path: "/swagger", + Handlers: []route.HandlerInterface{ + route.HTML[request.Void]().Get("", func(res *response.Response[[]byte], req *request.Request[request.Void]) { + res.Render(swagger.Index()) + }), + route.Text[request.Void]().Get("swagger.yml", func(res *response.Response[[]byte], req *request.Request[request.Void]) { + b, err := swagger.SwaggerYAML() + if err != nil { + res.InternalServerError(err) + return + } + + res.Render(b) + }), + }, + } + + apiv1 := route.GroupHandler{ + Path: "/api/v1", + } + apiv1.Add(users.Children()...) + apiv1.Add(swagger.Children()...) + app.WithDefaultMiddlewares(server, apiv1.Children()...) + route.Walk(apiv1.Children(), func(h middleware.Handler) { + server.Logger().Infof("%s", h.String()) + }) + + ctx := context.Background() + if err := server.Run(ctx); err != nil { + log.Fatalf("failed to run app: %s", err) + } +} diff --git a/examples/bare/internal/swagger/swagger.go b/examples/bare/internal/swagger/swagger.go new file mode 100644 index 0000000..c011634 --- /dev/null +++ b/examples/bare/internal/swagger/swagger.go @@ -0,0 +1,48 @@ +package swagger + +import ( + "embed" + "fmt" + "io/fs" +) + +const hostURL = "http://localhost:8080" + +func Index() []byte { + return []byte(fmt.Sprintf(` + +
+ +Status: %d
+ + + `, err, status)) + + if _, err := w.Write(body); err != nil { + panic(err) + } +} + +type TextAdapter struct{} + +func (a TextAdapter) Render(w http.ResponseWriter, payload any, status int) error { + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(status) + + body, ok := payload.([]byte) + if !ok { + return fmt.Errorf("body must be []byte but got %T", payload) + } + _, err := w.Write(body) + return err +} + +func (a TextAdapter) Error(w http.ResponseWriter, err error, status int) { + w.Header().Add("Content-Type", "text/plain") + w.WriteHeader(status) + + body := []byte(fmt.Sprintf(` + Error: %s \n + Status: %d + `, err, status)) + + if _, err := w.Write(body); err != nil { + panic(err) + } +} diff --git a/pkg/core/api/response/factory.go b/pkg/core/api/response/factory.go new file mode 100644 index 0000000..b088380 --- /dev/null +++ b/pkg/core/api/response/factory.go @@ -0,0 +1,17 @@ +package response + +import "net/http" + +func JSON[O any]() *Response[O] { + return &Response[O]{ + adapter: JSONAdapter{}, + status: http.StatusOK, + } +} + +func HTML[O any]() *Response[O] { + return &Response[O]{ + adapter: HTMLAdapter{}, + status: http.StatusOK, + } +} diff --git a/pkg/core/api/response/response.go b/pkg/core/api/response/response.go new file mode 100644 index 0000000..70717a3 --- /dev/null +++ b/pkg/core/api/response/response.go @@ -0,0 +1,67 @@ +package response + +import ( + "net/http" +) + +type Adapter interface { + Render(w http.ResponseWriter, payload any, status int) error + Error(w http.ResponseWriter, err error, status int) +} + +type Void struct{} + +type Response[O any] struct { + http.ResponseWriter + status int + adapter Adapter +} + +func New[O any](w http.ResponseWriter, a Adapter) *Response[O] { + return &Response[O]{ + ResponseWriter: w, + status: http.StatusOK, + adapter: a, + } +} + +func (r Response[O]) Render(o O) { + err := r.adapter.Render(r.ResponseWriter, o, r.status) + if err != nil { + r.adapter.Error(r.ResponseWriter, err, http.StatusInternalServerError) + } +} + +func (r *Response[O]) WriteHeader(code int) { + r.ResponseWriter.WriteHeader(code) + r.status = code +} + +func (r Response[O]) renderError(err error) { + r.adapter.Error(r.ResponseWriter, err, r.status) +} + +func (r Response[O]) InternalServerError(err error) { + r.status = http.StatusInternalServerError + r.renderError(err) +} + +func (r Response[O]) NotFound(err error) { + r.status = http.StatusNotFound + r.renderError(err) +} + +func (r Response[O]) BadRequest(err error) { + r.status = http.StatusBadRequest + r.renderError(err) +} + +func (r Response[O]) UnprocessableEntity(err error) { + r.status = http.StatusUnprocessableEntity + r.renderError(err) +} + +func (r Response[O]) Unauthorized(err error) { + r.status = http.StatusUnauthorized + r.renderError(err) +} diff --git a/pkg/controller/.keep b/pkg/core/api/route/.keep similarity index 100% rename from pkg/controller/.keep rename to pkg/core/api/route/.keep diff --git a/pkg/core/api/route/factory.go b/pkg/core/api/route/factory.go new file mode 100644 index 0000000..b9c142f --- /dev/null +++ b/pkg/core/api/route/factory.go @@ -0,0 +1,97 @@ +package route + +import ( + "net/http" + + "github.com/version-1/gooo/pkg/core/api/response" +) + +func JSON[I, O any]() *Handler[I, O] { + return &Handler[I, O]{ + adapter: response.JSONAdapter{}, + } +} + +func HTML[I any]() *Handler[I, []byte] { + return &Handler[I, []byte]{ + adapter: response.HTMLAdapter{}, + } +} + +func Text[I any]() *Handler[I, []byte] { + return &Handler[I, []byte]{ + adapter: response.TextAdapter{}, + } +} + +func (h *Handler[I, O]) Get(path string, handler HandlerFunc[I, O]) *Handler[I, O] { + h.Path = path + h.Method = http.MethodGet + h.handler = handler + + return h +} + +func (h *Handler[I, O]) Post(path string, handler HandlerFunc[I, O]) *Handler[I, O] { + h.Path = path + h.Method = http.MethodPost + h.handler = handler + + return h +} + +func (h *Handler[I, O]) Patch(path string, handler HandlerFunc[I, O]) *Handler[I, O] { + h.Path = path + h.Method = http.MethodPatch + h.handler = handler + + return h +} + +func (h *Handler[I, O]) Delete(path string, handler HandlerFunc[I, O]) *Handler[I, O] { + h.Path = path + h.Method = http.MethodDelete + h.handler = handler + + return h +} + +func Post[I, O any](path string, handler HandlerFunc[I, O]) *Handler[I, O] { + return &Handler[I, O]{ + Path: path, + Method: http.MethodPost, + handler: handler, + } +} + +func Get[I, O any](path string, handler HandlerFunc[I, O]) *Handler[I, O] { + return &Handler[I, O]{ + Path: path, + Method: http.MethodGet, + handler: handler, + } +} + +func Put[I, O any](path string, handler HandlerFunc[I, O]) *Handler[I, O] { + return &Handler[I, O]{ + Path: path, + Method: http.MethodPut, + handler: handler, + } +} + +func Patch[I, O any](path string, handler HandlerFunc[I, O]) *Handler[I, O] { + return &Handler[I, O]{ + Path: path, + Method: http.MethodPatch, + handler: handler, + } +} + +func Delete[I, O any](path string, handler HandlerFunc[I, O]) *Handler[I, O] { + return &Handler[I, O]{ + Path: path, + Method: http.MethodDelete, + handler: handler, + } +} diff --git a/pkg/core/api/route/group.go b/pkg/core/api/route/group.go new file mode 100644 index 0000000..44c3522 --- /dev/null +++ b/pkg/core/api/route/group.go @@ -0,0 +1,37 @@ +package route + +import ( + "github.com/version-1/gooo/pkg/toolkit/middleware" +) + +type GroupHandler struct { + Path string + // We use HandlerInterface instead of route.Handler because route.Handler is generic, + // which prevents us from determining the concrete type of the handler list. + Handlers []HandlerInterface +} + +type HandlerInterface interface { + middleware.Handler + ShiftPath(string) HandlerInterface +} + +func (g *GroupHandler) Add(h ...HandlerInterface) { + g.Handlers = append(g.Handlers, h...) +} + +func (g GroupHandler) Children() []HandlerInterface { + list := make([]HandlerInterface, len(g.Handlers)) + for i, h := range g.Handlers { + shifted := h.ShiftPath(g.Path) + list[i] = shifted + } + + return list +} + +func Walk(list []HandlerInterface, fn func(h middleware.Handler)) { + for _, h := range list { + fn(h) + } +} diff --git a/pkg/core/api/route/handler.go b/pkg/core/api/route/handler.go new file mode 100644 index 0000000..7b87067 --- /dev/null +++ b/pkg/core/api/route/handler.go @@ -0,0 +1,84 @@ +package route + +import ( + "fmt" + "net/http" + "net/url" + "path/filepath" + "strings" + + "github.com/version-1/gooo/pkg/core/api/request" + "github.com/version-1/gooo/pkg/core/api/response" + "github.com/version-1/gooo/pkg/toolkit/middleware" +) + +type HandlerFunc[I, O any] func(*response.Response[O], *request.Request[I]) + +var _ middleware.Handler = Handler[any, any]{} + +type Handler[I, O any] struct { + Path string + Method string + handler HandlerFunc[I, O] + params *Params + adapter response.Adapter +} + +func (h *Handler[I, O]) clone() *Handler[I, O] { + return &Handler[I, O]{ + Path: h.Path, + Method: h.Method, + handler: h.handler, + params: h.params, + adapter: h.adapter, + } +} + +func (h *Handler[I, O]) ShiftPath(base string) HandlerInterface { + cloned := h.clone() + cloned.Path = filepath.Clean(base + "/" + h.Path) + return cloned +} + +func (h Handler[I, O]) String() string { + return fmt.Sprintf("[%s] %s", h.Method, h.Path) +} + +func (h Handler[I, O]) Handler(res http.ResponseWriter, req *http.Request) { + p := h.Param(*req.URL) + customRequest := request.New[I](req, p) + customResponse := response.New[O](res, h.adapter) + h.handler(customResponse, customRequest) +} + +func (h Handler[I, O]) Match(r *http.Request) bool { + if r.Method != h.Method { + return false + } + + if r.URL.Path == h.Path { + return true + } + + parts := strings.Split(h.Path, "/") + targetParts := strings.Split(r.URL.Path, "/") + if len(parts) < len(targetParts) { + return false + } + + for i, part := range parts { + if !strings.HasPrefix(part, ":") && part != targetParts[i] { + return false + } + } + + return true +} + +func (h Handler[I, O]) Param(uri url.URL) *Params { + if h.params == nil { + p := parseParams(h.Path, uri.Path) + h.params = &p + } + return h.params +} diff --git a/pkg/core/api/route/params.go b/pkg/core/api/route/params.go new file mode 100644 index 0000000..b8052fa --- /dev/null +++ b/pkg/core/api/route/params.go @@ -0,0 +1,64 @@ +package route + +import ( + "fmt" + "strconv" + "strings" +) + +type Params struct { + m map[string]string +} + +func parseParams(matcher, path string) Params { + p := Params{m: make(map[string]string)} + + pathSegments := strings.Split(path, "/") + matcherSegments := strings.Split(matcher, "/") + for i, part := range matcherSegments { + if strings.HasPrefix(part, ":") { + if len(pathSegments) > i { + p.m[part] = pathSegments[i] + } + } + } + + return p +} + +func (p Params) GetBool(key string) (bool, error) { + v, err := p.GetString(key) + if err != nil { + return false, err + } + + b, err := strconv.ParseBool(v) + if err != nil { + return false, err + } + + return b, nil +} + +func (p Params) GetString(key string) (string, error) { + v, ok := p.m[key] + if !ok { + return "", fmt.Errorf("param %s not found", key) + } + + return v, nil +} + +func (p Params) GetInt(key string) (int, error) { + v, err := p.GetString(key) + if err != nil { + return 0, err + } + + n, err := strconv.Atoi(v) + if err != nil { + return 0, err + } + + return n, nil +} diff --git a/pkg/core/generator/generator.go b/pkg/core/generator/generator.go new file mode 100644 index 0000000..0150cf4 --- /dev/null +++ b/pkg/core/generator/generator.go @@ -0,0 +1,54 @@ +package generator + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/version-1/gooo/pkg/toolkit/errors" + "github.com/version-1/gooo/pkg/toolkit/util" +) + +type Generator struct { + Dir string + Template Template +} + +type Template interface { + Filename() string + Render() (string, error) +} + +func (g Generator) Run() error { + tmpl := g.Template + relativePath := filepath.Clean(fmt.Sprintf("%s/%s.go", g.Dir, tmpl.Filename())) + rootPath, err := util.LookupGomodDirPath() + if err != nil { + return err + } + filename := filepath.Clean(fmt.Sprintf("%s/%s", rootPath, relativePath)) + fmt.Println("Generating: ", relativePath) + s, err := g.Template.Render() + if err != nil { + return err + } + + return penetrateAndCreateFile(filename, s) +} + +func penetrateAndCreateFile(filename string, content string) error { + dir := filepath.Dir(filename) + err := os.MkdirAll(dir, os.ModePerm) + if err != nil { + return errors.Wrap(err) + } + + f, err := os.Create(filename) + if err != nil { + return errors.Wrap(err) + } + + defer f.Close() + _, err = f.WriteString(content) + return err +} diff --git a/pkg/core/schema/generate.go b/pkg/core/schema/generate.go new file mode 100644 index 0000000..811f2d4 --- /dev/null +++ b/pkg/core/schema/generate.go @@ -0,0 +1,40 @@ +package schema + +import ( + "fmt" + "path/filepath" + + "github.com/version-1/gooo/pkg/core/generator" + "github.com/version-1/gooo/pkg/core/schema/openapi/v3_0_0" + "github.com/version-1/gooo/pkg/core/schema/template" +) + +type Generator struct { + r *v3_0_0.RootSchema + outputs []generator.Template + baseURL string + OutDir string +} + +func NewGenerator(r *v3_0_0.RootSchema, outDir string, baseURL string) *Generator { + return &Generator{r: r, OutDir: outDir, baseURL: baseURL} +} + +func (g *Generator) Generate() error { + schemaFile := template.SchemaFile{Schema: g.r, PackageName: "schema"} + mainFile := template.Main{Schema: g.r} + + mainFile.Dependencies = []string{fmt.Sprintf("%s/%s", g.baseURL, filepath.Dir(schemaFile.Filename()))} + + g.outputs = append(g.outputs, schemaFile) + g.outputs = append(g.outputs, mainFile) + + for _, tmpl := range g.outputs { + g := generator.Generator{Dir: g.OutDir, Template: tmpl} + if err := g.Run(); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/core/schema/openapi/schema.go b/pkg/core/schema/openapi/schema.go new file mode 100644 index 0000000..9a9a3ed --- /dev/null +++ b/pkg/core/schema/openapi/schema.go @@ -0,0 +1,5 @@ +package openapi + +import "github.com/version-1/gooo/pkg/core/schema/openapi/v3_0_0" + +type RootSchema v3_0_0.RootSchema diff --git a/pkg/core/schema/openapi/v3_0_0/schema.go b/pkg/core/schema/openapi/v3_0_0/schema.go new file mode 100644 index 0000000..6dc4ae1 --- /dev/null +++ b/pkg/core/schema/openapi/v3_0_0/schema.go @@ -0,0 +1,104 @@ +package v3_0_0 + +import ( + "os" + + "github.com/version-1/gooo/pkg/core/schema/openapi/yaml" +) + +func New(path string) (*RootSchema, error) { + bytes, err := os.ReadFile(path) + if err != nil { + return nil, err + } + s := &RootSchema{} + if err := yaml.Unmarshal(bytes, &s); err != nil { + return s, err + } + + return s, nil +} + +type RequestBody struct { + Description string `json:"description"` + Content yaml.OrderedMap[MediaType] `json:"content"` +} + +type Response struct { + Description string `json:"description"` + Content yaml.OrderedMap[MediaType] `json:"content"` +} + +type MediaType struct { + Schema Schema `json:"schema"` +} + +type Content struct { + Schema RootSchema `json:"schema"` +} + +type Parameter struct { + Name string `json:"name"` + In string `json:"in"` + Description string `json:"description"` + Required bool `json:"required"` + Schema RootSchema `json:"schema"` +} + +type Operation struct { + Summary string `json:"summary"` + Description string `json:"description"` + OperationId string `json:"operationId"` + Parameters []Parameter `json:"parameters"` + RequestBody RequestBody `json:"requestBody" yaml:"requestBody"` + Responses yaml.OrderedMap[Response] `json:"responses"` +} + +type PathItem struct { + Get *Operation `json:"get"` + Post *Operation `json:"post"` + Put *Operation `json:"put"` + Patch *Operation `json:"patch"` + Delete *Operation `json:"delete"` +} + +type Info struct { + Title string `json:"title"` + Description string `json:"description"` + Version string `json:"version"` +} + +type Server struct { + Url string `json:"url"` + Description string `json:"description"` +} + +type Components struct { + Schemas yaml.OrderedMap[Schema] `json:"schemas"` +} + +type Schema struct { + Type string `json:"type"` + Properties yaml.OrderedMap[Property] `json:"properties"` + Ref string `json:"$ref" yaml:"$ref"` + Items Property `json:"items"` +} + +type Property struct { + Ref string `json:"$ref" yaml:"$ref"` + Type string `json:"type"` + Properties yaml.OrderedMap[Property] `json:"properties"` + Items *Property `json:"items"` + Format string `json:"format"` + Sample string `json:"sample"` + Required bool `json:"required"` +} + +// version. 3.0.x +type RootSchema struct { + OpenAPI string `json:"openapi"` + Info Info `json:"info"` + Paths yaml.OrderedMap[PathItem] `json:"paths"` + Servers []Server `json:"servers"` + Components Components `json:"components"` +} diff --git a/pkg/core/schema/openapi/yaml/yaml.go b/pkg/core/schema/openapi/yaml/yaml.go new file mode 100644 index 0000000..b03e52d --- /dev/null +++ b/pkg/core/schema/openapi/yaml/yaml.go @@ -0,0 +1,66 @@ +package yaml + +import ( + yaml "gopkg.in/yaml.v3" +) + +func Unmarshal(b []byte, d any) error { + return yaml.Unmarshal(b, d) +} + +type OrderedMap[T any] struct { + keys []string + Values map[string]T +} + +func (o *OrderedMap[T]) Set(key string, value T) { + o.keys = append(o.keys, key) + o.Values[key] = value +} + +func (o OrderedMap[T]) Get(key string) T { + return o.Values[key] +} + +func (o OrderedMap[T]) Each(cb func(key string, v T) error) error { + for _, key := range o.keys { + err := cb(key, o.Values[key]) + if err != nil { + return err + } + } + + return nil +} + +func (o OrderedMap[T]) Index(i int) (string, T) { + key := o.keys[i] + return key, o.Values[key] +} + +func (o OrderedMap[T]) Len() int { + return len(o.keys) +} + +func (o OrderedMap[T]) Keys() []string { + return o.keys +} + +func (o *OrderedMap[T]) UnmarshalYAML(node *yaml.Node) error { + if node.Kind != yaml.MappingNode { + return nil + } + + o.Values = make(map[string]T) + for i := 0; i < len(node.Content); i += 2 { + key := node.Content[i].Value + value := node.Content[i+1] + var v T + if err := value.Decode(&v); err != nil { + return err + } + o.Set(key, v) + } + + return nil +} diff --git a/pkg/core/schema/openapi/yaml/yaml_test.go b/pkg/core/schema/openapi/yaml/yaml_test.go new file mode 100644 index 0000000..abc7a78 --- /dev/null +++ b/pkg/core/schema/openapi/yaml/yaml_test.go @@ -0,0 +1,8 @@ +package yaml + +import ( + "testing" +) + +func TestYaml_Load(t *testing.T) { +} diff --git a/pkg/core/schema/template/components/entry.go.tmpl b/pkg/core/schema/template/components/entry.go.tmpl new file mode 100644 index 0000000..6697103 --- /dev/null +++ b/pkg/core/schema/template/components/entry.go.tmpl @@ -0,0 +1,39 @@ +package main + +// This is a generated file. DO NOT EDIT manually. +import ( + {{ range .Dependencies }} + "{{ . }}" + {{ end }} +) + +func main() { + cfg := &app.Config{} + cfg.SetLogger(logger.DefaultLogger) + + server := &app.App{ + Addr: ":8080", + Config: cfg, + ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + cfg.Logger().Errorf("Error: %+v", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal server error")) + }, + } + + RegisterRoutes(server) + ctx := context.Background() + if err := server.Run(ctx); err != nil { + log.Fatalf("failed to run app: %s", err) + } +} + +func RegisterRoutes(srv *app.App) { + routes := route.GroupHandler{ + Path: "/users", + Handlers: []route.HandlerInterface{ + {{ .Routes }} + }, + } + app.WithDefaultMiddlewares(srv, routes.Children()...) +} diff --git a/pkg/core/schema/template/components/file.go.tmpl b/pkg/core/schema/template/components/file.go.tmpl new file mode 100644 index 0000000..b67093c --- /dev/null +++ b/pkg/core/schema/template/components/file.go.tmpl @@ -0,0 +1,10 @@ +package {{ .PackageName }} + +import ( + {{ range .Dependencies }} + "{{ . }}" + {{ end }} +) +// This is a generated file. DO NOT EDIT manually. + +{{ .Content }} diff --git a/pkg/core/schema/template/components/route.go.tmpl b/pkg/core/schema/template/components/route.go.tmpl new file mode 100644 index 0000000..5495507 --- /dev/null +++ b/pkg/core/schema/template/components/route.go.tmpl @@ -0,0 +1,3 @@ +route.JSON[{{.InputType}}, {{.OutputType}}]().{{.Method}}("{{.Path}}", func(res *response.Response[{{.OutputType}}], req *request.Request[{{.InputType}}]) { + // do something +}), diff --git a/pkg/core/schema/template/components/struct.go.tmpl b/pkg/core/schema/template/components/struct.go.tmpl new file mode 100644 index 0000000..f9be4c5 --- /dev/null +++ b/pkg/core/schema/template/components/struct.go.tmpl @@ -0,0 +1,4 @@ +type {{.TypeName}} struct { + {{range .Fields}}{{.}} + {{end}} +} diff --git a/pkg/core/schema/template/file.go b/pkg/core/schema/template/file.go new file mode 100644 index 0000000..27289f2 --- /dev/null +++ b/pkg/core/schema/template/file.go @@ -0,0 +1,7 @@ +package template + +type file struct { + Dependencies []string + PackageName string + Content string +} diff --git a/pkg/core/schema/template/format.go b/pkg/core/schema/template/format.go new file mode 100644 index 0000000..19827e3 --- /dev/null +++ b/pkg/core/schema/template/format.go @@ -0,0 +1,32 @@ +package template + +import ( + "fmt" + "go/format" + + "golang.org/x/tools/imports" +) + +func pretify(filename, s string) ([]byte, error) { + formatted, err := format.Source([]byte(s)) + if err != nil { + fmt.Println("Error processing format", s) + return []byte{}, err + } + + processed, err := imports.Process(filename, formatted, nil) + if err != nil { + fmt.Println("Error processing imports", s) + return formatted, err + } + + return processed, nil +} + +func Capitalize(s string) string { + if len(s) == 0 { + return s + } + + return string(s[0]-32) + s[1:] +} diff --git a/pkg/core/schema/template/main.go b/pkg/core/schema/template/main.go new file mode 100644 index 0000000..78f4b2c --- /dev/null +++ b/pkg/core/schema/template/main.go @@ -0,0 +1,44 @@ +package template + +import ( + "bytes" + "embed" + "text/template" + + "github.com/version-1/gooo/pkg/core/schema/openapi/v3_0_0" + "github.com/version-1/gooo/pkg/toolkit/errors" +) + +//go:embed components/*.go.tmpl +var tmpl embed.FS + +type Main struct { + Schema *v3_0_0.RootSchema + Dependencies []string + Routes string +} + +func (m Main) Filename() string { + return "main" +} + +func (m Main) Render() (string, error) { + routes, err := renderRoutes(extractRoutes(m.Schema)) + if err != nil { + return "", err + } + + m.Routes = routes + + tmpl := template.Must(template.New("entry").ParseFS(tmpl, "components/entry.go.tmpl")) + var b bytes.Buffer + if err := tmpl.ExecuteTemplate(&b, "entry.go.tmpl", m); err != nil { + return "", err + } + + res, err := pretify(m.Filename(), b.String()) + if err != nil { + return "", errors.Wrap(err) + } + return string(res), err +} diff --git a/pkg/core/schema/template/namespace.go b/pkg/core/schema/template/namespace.go new file mode 100644 index 0000000..9c7fb9f --- /dev/null +++ b/pkg/core/schema/template/namespace.go @@ -0,0 +1,15 @@ +package template + +import ( + "fmt" + "strings" +) + +func withSchemaPackageName(schemaName string) string { + return fmt.Sprintf("schema.%s", schemaName) +} + +func schemaTypeName(schemaName string) string { + segments := strings.Split(schemaName, "/") + return segments[len(segments)-1] +} diff --git a/pkg/core/schema/template/partial/partial.go b/pkg/core/schema/template/partial/partial.go new file mode 100644 index 0000000..f2e7013 --- /dev/null +++ b/pkg/core/schema/template/partial/partial.go @@ -0,0 +1,12 @@ +package partial + +import ( + "fmt" + "strings" +) + +func AnonymousStruct(fields []string) string { + return fmt.Sprintf(`struct { + %s + }`, strings.Join(fields, "\n")) +} diff --git a/pkg/core/schema/template/route.go b/pkg/core/schema/template/route.go new file mode 100644 index 0000000..54ee7d3 --- /dev/null +++ b/pkg/core/schema/template/route.go @@ -0,0 +1,106 @@ +package template + +import ( + "bytes" + "strconv" + "strings" + "text/template" + + "github.com/version-1/gooo/pkg/core/schema/openapi/v3_0_0" + "github.com/version-1/gooo/pkg/core/schema/openapi/yaml" + "github.com/version-1/gooo/pkg/toolkit/errors" +) + +type Route struct { + InputType string + OutputType string + Method string + Path string +} + +func renderRoutes(routes []Route) (string, error) { + var b bytes.Buffer + for _, r := range routes { + tmpl := template.Must(template.New("route").ParseFS(tmpl, "components/route.go.tmpl")) + if err := tmpl.ExecuteTemplate(&b, "route.go.tmpl", r); err != nil { + return "", errors.Wrap(err) + } + } + return b.String(), nil +} + +func extractRoutes(r *v3_0_0.RootSchema) []Route { + routes := []Route{} + r.Paths.Each(func(path string, pathItem v3_0_0.PathItem) error { + m := map[string]*v3_0_0.Operation{ + "Get": pathItem.Get, + "Post": pathItem.Post, + "Patch": pathItem.Patch, + "Put": pathItem.Put, + "Delete": pathItem.Delete, + } + for k, v := range m { + if v == nil { + continue + } + + if k == "Get" || k == "Delete" { + route := Route{ + InputType: "request.Void", + OutputType: withSchemaPackageName(detectOutputType(v, 200, "application/json")), + Method: k, + Path: path, + } + + routes = append(routes, route) + } else { + statusCode := 200 + if k == "Post" { + statusCode = 201 + } + route := Route{ + InputType: withSchemaPackageName(detectInputType(v, "application/json")), + OutputType: withSchemaPackageName(detectOutputType(v, statusCode, "application/json")), + Method: k, + Path: path, + } + routes = append(routes, route) + } + } + + return nil + }) + + return routes +} + +func detectInputType(op *v3_0_0.Operation, contentType string) string { + schema := op.RequestBody.Content.Get(contentType).Schema + ref := "" + if schema.Ref != "" { + ref = schema.Ref + } + + if schema.Items.Type == "array" && schema.Items.Ref != "" { + ref = schema.Items.Ref + } + + schemaName := strings.Replace(ref, "#/components/schemas/", "", 1) + return schemaName +} + +func detectOutputType(op *v3_0_0.Operation, statusCode int, contentType string) string { + responses := yaml.OrderedMap[v3_0_0.Response](op.Responses) + schema := responses.Get(strconv.Itoa(statusCode)).Content.Get(contentType).Schema + ref := "" + if schema.Ref != "" { + ref = schema.Ref + } + + if schema.Type == "array" && schema.Items.Ref != "" { + ref = schema.Items.Ref + } + + schemaName := strings.Replace(ref, "#/components/schemas/", "", 1) + return schemaName +} diff --git a/pkg/core/schema/template/schema.go b/pkg/core/schema/template/schema.go new file mode 100644 index 0000000..95933ef --- /dev/null +++ b/pkg/core/schema/template/schema.go @@ -0,0 +1,185 @@ +package template + +import ( + "bytes" + "fmt" + "strings" + "text/template" + + "github.com/version-1/gooo/pkg/core/schema/openapi/v3_0_0" + "github.com/version-1/gooo/pkg/core/schema/openapi/yaml" + "github.com/version-1/gooo/pkg/core/schema/template/partial" + "github.com/version-1/gooo/pkg/toolkit/errors" +) + +type SchemaFile struct { + Schema *v3_0_0.RootSchema + PackageName string + Content string +} + +func (s SchemaFile) Filename() string { + return "internal/schema/schema" +} + +func (s SchemaFile) Render() (string, error) { + schemas := []Schema{} + err := s.Schema.Components.Schemas.Each(func(key string, s v3_0_0.Schema) error { + fields, err := extractFields(s.Properties, "") + if err != nil { + return err + } + + schemas = append(schemas, Schema{ + Fields: fields, + TypeName: key, + }) + return nil + }) + if err != nil { + return "", err + } + + content, err := renderSchemas(schemas) + if err != nil { + return "", err + } + + f := file{ + PackageName: s.PackageName, + Content: content, + } + + tmpl := template.Must(template.New("file").ParseFS(tmpl, "components/file.go.tmpl")) + var b bytes.Buffer + if err := tmpl.ExecuteTemplate(&b, "file.go.tmpl", f); err != nil { + return "", err + } + + res, err := pretify(s.Filename(), b.String()) + if err != nil { + return "", errors.Wrap(err) + } + return string(res), err +} + +type Schema struct { + Fields []string + TypeName string +} + +func renderSchemas(schemas []Schema) (string, error) { + var b bytes.Buffer + for _, s := range schemas { + tmpl := template.Must(template.New("struct").ParseFS(tmpl, "components/struct.go.tmpl")) + if err := tmpl.ExecuteTemplate(&b, "struct.go.tmpl", s); err != nil { + return "", errors.Wrap(err) + } + b.WriteString("\n") + } + return b.String(), nil +} + +func extractFields(props yaml.OrderedMap[v3_0_0.Property], prefix string) ([]string, error) { + var fields []string + for i := 0; i < props.Len(); i++ { + k, v := props.Index(i) + key := formatKeyname(k) + if v.Ref != "" { + fields = append(fields, key+" "+pointer(schemaTypeName(v.Ref))) + continue + } + + t, err := extractFieldType(v, prefix) + if err != nil { + return []string{}, err + } + fields = append(fields, key+" "+t) + } + return fields, nil +} + +func extractFieldType(prop v3_0_0.Property, prefix string) (string, error) { + if prop.Ref != "" { + return prefix + pointer(schemaTypeName(prop.Ref)), nil + } + + switch { + case isPrimitive(prop.Type): + return prefix + convertGoType(prop.Type), nil + case isDate(prop.Type): + return prefix + "time.Time", nil + case isObject(prop.Type): + fields, err := extractFields(prop.Properties, prefix) + if err != nil { + return "", err + } + return prefix + partial.AnonymousStruct(fields), nil + case isArray(prop.Type): + if prop.Items == nil { + return "", fmt.Errorf("Array must have items properties. %s\n", prop.Type) + } + return extractFieldType(*prop.Items, prefix+"[]") + default: + return "", fmt.Errorf("Unknown type: %s\n", prop.Type) + } +} + +func pointer(typeName string) string { + return "*" + typeName +} + +func formatKeyname(key string) string { + if key == "id" { + return strings.ToUpper(key) + } + + if strings.HasSuffix(key, "Id") { + return key[0:len(key)-2] + "ID" + } + + return Capitalize(key) +} + +func convertGoType(t string) string { + m := map[string]string{ + "string": "string", + "number": "int", + "integer": "int", + "boolean": "bool", + "byte": "[]byte", + } + v, ok := m[t] + if !ok { + return t + } + return v +} + +func isPrimitive(t string) bool { + primitives := map[string]bool{ + "string": true, + "number": true, + "integer": true, + "boolean": true, + "byte": true, + } + _, ok := primitives[t] + return ok +} + +func isComplex(t string) bool { + return !isPrimitive(t) +} + +func isArray(t string) bool { + return t == "array" +} + +func isObject(t string) bool { + return t == "object" +} + +func isDate(t string) bool { + return t == "date" +} diff --git a/pkg/db/db.go b/pkg/datasource/db/db.go similarity index 98% rename from pkg/db/db.go rename to pkg/datasource/db/db.go index 3ceb4a5..2346a1d 100644 --- a/pkg/db/db.go +++ b/pkg/datasource/db/db.go @@ -7,7 +7,7 @@ import ( "github.com/google/uuid" "github.com/jmoiron/sqlx" - "github.com/version-1/gooo/pkg/logger" + "github.com/version-1/gooo/pkg/toolkit/logger" ) type QueryRunner interface { diff --git a/pkg/db/logger.go b/pkg/datasource/db/logger.go similarity index 97% rename from pkg/db/logger.go rename to pkg/datasource/db/logger.go index e38aee6..35d6bfb 100644 --- a/pkg/db/logger.go +++ b/pkg/datasource/db/logger.go @@ -4,7 +4,7 @@ import ( "fmt" "strings" - "github.com/version-1/gooo/pkg/logger" + "github.com/version-1/gooo/pkg/toolkit/logger" ) type QueryLogger interface { diff --git a/pkg/errors/errors_test.go b/pkg/errors/errors_test.go deleted file mode 100644 index 236c6a8..0000000 --- a/pkg/errors/errors_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package errors - -import ( - "fmt" - "strings" - "testing" -) - -func TestErrors(t *testing.T) { - err := New("msg") - - tests := []struct { - name string - subject func() string - expect string - }{ - { - name: "Stacktrace", - subject: func() string { - return err.StackTrace() - }, - expect: strings.Join([]string{ - "/Users/admin/Projects/Private/gooo/pkg/errors/errors_test.go:github.com/version-1/gooo/pkg/errors.TestErrors:10", - "/usr/local/Cellar/go/1.22.3/libexec/src/testing/testing.go:testing.tRunner:1689", - "/usr/local/Cellar/go/1.22.3/libexec/src/runtime/asm_amd64.s:runtime.goexit:1695", - }, "\n") + "\n", - }, - { - name: "Print Error with +v", - subject: func() string { - return fmt.Sprintf("%+v", err) - }, - expect: strings.Join([]string{ - "/Users/admin/Projects/Private/gooo/pkg/errors/errors_test.go:github.com/version-1/gooo/pkg/errors.TestErrors:10", - "/usr/local/Cellar/go/1.22.3/libexec/src/testing/testing.go:testing.tRunner:1689", - "/usr/local/Cellar/go/1.22.3/libexec/src/runtime/asm_amd64.s:runtime.goexit:1695", - }, "\n") + "\n", - }, - { - name: "Print Error with v", - subject: func() string { - return fmt.Sprintf("%v", err) - }, - expect: "pkg/errors : msg", - }, - { - name: "Print Error with s", - subject: func() string { - return fmt.Sprintf("%s", err) - }, - expect: "pkg/errors : msg", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - if got := test.subject(); strings.TrimSpace(got) != strings.TrimSpace(test.expect) { - t.Errorf("expected\n %s, got\n %s", test.expect, got) - fmt.Printf("expected len %d, expected got %d", len(test.expect), len(got)) - - for i, c := range test.expect { - if string(c) != string(got[i]) { - t.Errorf("%d. expected %s, got %s", i, string(c), string(got[i])) - } - } - } - }) - } -} diff --git a/pkg/generator/generator.go b/pkg/generator/generator.go deleted file mode 100644 index 9c2113e..0000000 --- a/pkg/generator/generator.go +++ /dev/null @@ -1,38 +0,0 @@ -package generator - -import ( - "fmt" - "os" - "path/filepath" -) - -type Generator struct { - Dir string - Template Template -} - -type Template interface { - Filename() string - Render() (string, error) -} - -func (g Generator) Run() error { - tmpl := g.Template - filename := filepath.Clean(fmt.Sprintf("%s/%s.go", g.Dir, tmpl.Filename())) - fmt.Println("Generating: ", filename) - s, err := g.Template.Render() - if err != nil { - return err - } - - f, err := os.Create(filename) - if err != nil { - return err - } - - defer f.Close() - - f.WriteString(s) - - return nil -} diff --git a/pkg/http/request/request.go b/pkg/http/request/request.go deleted file mode 100644 index 847def6..0000000 --- a/pkg/http/request/request.go +++ /dev/null @@ -1,49 +0,0 @@ -package request - -import ( - gocontext "context" - "encoding/json" - "io" - "net/http" - - "github.com/version-1/gooo/pkg/context" - "github.com/version-1/gooo/pkg/logger" -) - -type ParamParser interface { - Param(url string, key string) (string, bool) - ParamInt(url string, key string) (int, bool) -} - -type Request struct { - Handler ParamParser - *http.Request -} - -func MarshalBody[T json.Unmarshaler](r *Request, obj *T) error { - b, err := io.ReadAll(r.Request.Body) - if err != nil { - return err - } - defer r.Request.Body.Close() - - return json.Unmarshal(b, obj) -} - -func (r Request) Logger() logger.Logger { - cfg := context.AppConfig(r.Request.Context()) - return cfg.Logger -} - -func (r Request) Param(key string) (string, bool) { - return r.Handler.Param(r.Request.URL.Path, key) -} - -func (r Request) ParamInt(key string) (int, bool) { - return r.Handler.ParamInt(r.Request.URL.Path, key) -} - -func (r *Request) WithContext(ctx gocontext.Context) *Request { - r.Request = r.Request.WithContext(ctx) - return r -} diff --git a/pkg/http/response/adapter/jsonapi.go b/pkg/http/response/adapter/jsonapi.go deleted file mode 100644 index fa47775..0000000 --- a/pkg/http/response/adapter/jsonapi.go +++ /dev/null @@ -1,128 +0,0 @@ -package adapter - -import ( - "fmt" - "net/http" - - goooerrors "github.com/version-1/gooo/pkg/errors" - "github.com/version-1/gooo/pkg/presenter/jsonapi" -) - -type JSONAPI struct { - payload any - meta jsonapi.Serializer -} - -type JSONAPIOption struct { - Meta jsonapi.Serializer -} - -type JSONAPIInvalidTypeError struct { - Payload any -} - -func (e JSONAPIInvalidTypeError) Error() string { - return fmt.Sprintf("Invalid payload type. Payload must implement jsonapi.Resourcer. got: %T", e.Payload) -} - -func (a JSONAPI) Render(w http.ResponseWriter, payload any, options ...any) error { - b, err := a.resolve(payload, options...) - if err != nil { - return err - } - - w.Header().Set("Content-Type", "application/vnd.api+json") - _, err = w.Write(b) - return err -} - -func (a JSONAPI) RenderError(w http.ResponseWriter, e error, options ...any) error { - b, errors, err := a.resolveError(e, options...) - if err != nil { - fmt.Println("error ==========", err) - return err - } - - w.Header().Set("Content-Type", "application/vnd.api+json") - if len(errors) > 0 { - w.WriteHeader(errors[0].Status) - } - _, err = w.Write(b) - return err -} - -func (a JSONAPI) InternalServerError(w http.ResponseWriter, e error, options ...any) error { - err := jsonapi.NewInternalServerError(e) - return a.RenderError(w, err, options...) -} - -func (a JSONAPI) BadRequest(w http.ResponseWriter, e error, options ...any) error { - err := jsonapi.NewBadRequest(e) - return a.RenderError(w, err, options...) -} - -func (a JSONAPI) NotFound(w http.ResponseWriter, e error, options ...any) error { - err := jsonapi.NewNotFound(e) - return a.RenderError(w, err, options...) -} - -func (a JSONAPI) Unauthorized(w http.ResponseWriter, e error, options ...any) error { - err := jsonapi.NewUnauthorized(e) - return a.RenderError(w, err, options...) -} - -func (a JSONAPI) Forbidden(w http.ResponseWriter, e error, options ...any) error { - err := jsonapi.NewForbidden(e) - return a.RenderError(w, err, options...) -} - -func (a JSONAPI) resolve(payload any, options ...any) ([]byte, error) { - var meta jsonapi.Serializer - for _, opt := range options { - if t, ok := opt.(*JSONAPIOption); ok { - meta = t.Meta - } - } - - switch v := payload.(type) { - case jsonapi.Resourcer: - data, includes := v.ToJSONAPIResource() - s, err := jsonapi.New(data, includes, meta).Serialize() - return []byte(s), err - case []jsonapi.Resourcer: - list := jsonapi.Resources{} - includes := jsonapi.Resources{} - for _, ele := range v { - r, appending := ele.ToJSONAPIResource() - list.Append(r) - includes.Append(appending.Data...) - } - s, err := jsonapi.NewMany(list, includes, meta).Serialize() - - return []byte(s), err - default: - return []byte{}, JSONAPIInvalidTypeError{Payload: v} - } -} - -func (a JSONAPI) resolveError(e error, options ...any) ([]byte, []jsonapi.Error, error) { - switch v := e.(type) { - case jsonapi.Errors: - s, err := jsonapi.NewErrors(v).Serialize() - return []byte(s), v, err - case jsonapi.Error: - errors := jsonapi.Errors{v} - s, err := jsonapi.NewErrors(errors).Serialize() - return []byte(s), errors, err - case jsonapi.Errable: - obj := v.ToJSONAPIError() - errors := jsonapi.Errors{obj} - s, err := jsonapi.NewErrors(errors).Serialize() - return []byte(s), errors, goooerrors.New(err.Error()) - default: - obj := jsonapi.NewErrorResponse(v).ToJSONAPIError() - errors := jsonapi.Errors{obj} - s, err := jsonapi.NewErrors(errors).Serialize() - return []byte(s), errors, err - } -} diff --git a/pkg/http/response/adapter/raw.go b/pkg/http/response/adapter/raw.go deleted file mode 100644 index 5ed8875..0000000 --- a/pkg/http/response/adapter/raw.go +++ /dev/null @@ -1,41 +0,0 @@ -package adapter - -import ( - "encoding/json" - "net/http" -) - -type Raw struct{} - -func (a Raw) Render(w http.ResponseWriter, payload any, options ...any) error { - return json.NewEncoder(w).Encode(payload) -} - -func (a Raw) RenderError(w http.ResponseWriter, e error, options ...any) error { - return json.NewEncoder(w).Encode(e) -} - -func (a Raw) InternalServerError(w http.ResponseWriter, e error, options ...any) error { - w.WriteHeader(http.StatusInternalServerError) - return a.RenderError(w, e, options...) -} - -func (a Raw) BadRequest(w http.ResponseWriter, e error, options ...any) error { - w.WriteHeader(http.StatusBadRequest) - return a.RenderError(w, e, options...) -} - -func (a Raw) NotFound(w http.ResponseWriter, e error, options ...any) error { - w.WriteHeader(http.StatusNotFound) - return a.RenderError(w, e, options...) -} - -func (a Raw) Unauthorized(w http.ResponseWriter, e error, options ...any) error { - w.WriteHeader(http.StatusUnauthorized) - return a.RenderError(w, e, options...) -} - -func (a Raw) Forbidden(w http.ResponseWriter, e error, options ...any) error { - w.WriteHeader(http.StatusForbidden) - return a.RenderError(w, e, options...) -} diff --git a/pkg/http/response/response.go b/pkg/http/response/response.go deleted file mode 100644 index 064678b..0000000 --- a/pkg/http/response/response.go +++ /dev/null @@ -1,145 +0,0 @@ -package response - -import ( - "encoding/json" - "net/http" - - "github.com/version-1/gooo/pkg/http/response/adapter" -) - -var _ http.ResponseWriter = &Response{} - -var jsonapiAdapter Renderer = adapter.JSONAPI{} -var rawAdapter Renderer = adapter.Raw{} - -type Renderer interface { - Render(w http.ResponseWriter, payload any, options ...any) error - RenderError(w http.ResponseWriter, err error, options ...any) error - InternalServerError(w http.ResponseWriter, err error, options ...any) error - NotFound(w http.ResponseWriter, err error, options ...any) error - BadRequest(w http.ResponseWriter, err error, options ...any) error - Unauthorized(w http.ResponseWriter, err error, options ...any) error - Forbidden(w http.ResponseWriter, err error, options ...any) error -} - -type Options struct { - Adapter string -} - -type Response struct { - ResponseWriter http.ResponseWriter - adapter Renderer - options Options -} - -func New(r http.ResponseWriter, opts Options) *Response { - adp := rawAdapter - switch opts.Adapter { - case "jsonapi": - adp = jsonapiAdapter - default: - opts.Adapter = "raw" - } - - return &Response{ - ResponseWriter: r, - adapter: adp, - options: opts, - } -} - -func (r Response) Adapter() Renderer { - if r.adapter != nil { - return r.adapter - } - - return rawAdapter -} - -func (r *Response) SetAdapter(adp Renderer) *Response { - r.adapter = adp - return r -} - -func (r *Response) JSON(payload any) *Response { - r.Header().Set("Content-Type", "application/json") - json.NewEncoder(r.ResponseWriter).Encode(payload) - - return r -} - -func (r *Response) Body(payload string) *Response { - r.ResponseWriter.Write([]byte(payload)) - - return r -} - -func (r *Response) Status(code int) *Response { - r.ResponseWriter.WriteHeader(code) - return r -} - -func (r *Response) Render(payload any, options ...any) error { - return r.Adapter().Render(r.ResponseWriter, payload, options...) -} - -func (r *Response) RenderError(payload error, options ...any) error { - return r.Adapter().Render(r.ResponseWriter, payload, options...) -} - -func (r *Response) SetHeader(key, value string) *Response { - r.ResponseWriter.Header().Set(key, value) - return r -} - -func (r Response) Header() http.Header { - return r.ResponseWriter.Header() -} - -func (r *Response) Write(b []byte) (int, error) { - return r.ResponseWriter.Write(b) -} - -func (r *Response) WriteHeader(statusCode int) { - r.ResponseWriter.WriteHeader(statusCode) -} - -func (r *Response) InternalServerError() *Response { - return r.Status(http.StatusInternalServerError) -} - -func (r *Response) NotFound() *Response { - return r.Status(http.StatusNotFound) -} - -func (r *Response) BadRequest() *Response { - return r.Status(http.StatusBadRequest) -} - -func (r *Response) Unauthorized() *Response { - return r.Status(http.StatusUnauthorized) -} - -func (r *Response) Forbidden() *Response { - return r.Status(http.StatusForbidden) -} - -func (r *Response) InternalServerErrorWith(e error, options ...any) error { - return r.Adapter().InternalServerError(r.ResponseWriter, e, options...) -} - -func (r *Response) NotFoundWith(e error, options ...any) error { - return r.Adapter().NotFound(r.ResponseWriter, e, options...) -} - -func (r *Response) BadRequestWith(e error, options ...any) error { - return r.Adapter().BadRequest(r.ResponseWriter, e, options...) -} - -func (r *Response) UnauthorizedWith(e error, options ...any) error { - return r.Adapter().Unauthorized(r.ResponseWriter, e, options...) -} - -func (r *Response) ForbiddenWith(e error, options ...any) error { - return r.Adapter().Forbidden(r.ResponseWriter, e, options...) -} diff --git a/pkg/presenter/jsonapi/stringify.go b/pkg/presenter/jsonapi/stringify.go deleted file mode 100644 index 0e49358..0000000 --- a/pkg/presenter/jsonapi/stringify.go +++ /dev/null @@ -1,63 +0,0 @@ -package jsonapi - -import ( - "fmt" - "strconv" - "time" -) - -func Stringify(v any) string { - if v == nil { - return "null" - } - - switch v := v.(type) { - case string: - return fmt.Sprintf("\"%s\"", v) - case *string: - if v == nil { - return "null" - } - - return fmt.Sprintf("\"%s\"", *v) - case int: - return strconv.Itoa(v) - case *int: - if v == nil { - return "null" - } - return strconv.Itoa(*v) - case bool: - return strconv.FormatBool(v) - case *bool: - if v == nil { - return "null" - } - return strconv.FormatBool(*v) - case float64: - return strconv.FormatFloat(v, 'f', -1, 64) - case *float64: - if v == nil { - return "null" - } - return strconv.FormatFloat(*v, 'f', -1, 32) - case float32: - return strconv.FormatFloat(float64(v), 'f', -1, 32) - case *float32: - if v == nil { - return "null" - } - return strconv.FormatFloat(float64(*v), 'f', -1, 32) - case time.Time: - return fmt.Sprintf("\"%s\"", v.Format("2006-01-02T15:04:05 -0700")) - case *time.Time: - if v == nil { - return "null" - } - return fmt.Sprintf("\"%s\"", v.Format("2006-01-02T15:04:05 -0700")) - case fmt.Stringer: - return fmt.Sprintf("\"%s\"", v) - default: - return fmt.Sprintf("\"%s\"", v) - } -} diff --git a/pkg/schema/colletion.go b/pkg/schema/colletion.go deleted file mode 100644 index 19d8b0e..0000000 --- a/pkg/schema/colletion.go +++ /dev/null @@ -1,145 +0,0 @@ -package schema - -import ( - "fmt" - "strings" - - "github.com/version-1/gooo/pkg/generator" - "github.com/version-1/gooo/pkg/schema/internal/template" -) - -var errorsPackage = fmt.Sprintf("goooerrors \"%s\"", "github.com/version-1/gooo/pkg/datasource/orm/errors") -var schemaPackage = "\"github.com/version-1/gooo/pkg/schema\"" -var stringsPackage = "gooostrings \"github.com/version-1/gooo/pkg/strings\"" -var jsonapiPackage = "\"github.com/version-1/gooo/pkg/presenter/jsonapi\"" - -type SchemaCollection struct { - URL string - Dir string - Package string - Schemas []Schema -} - -func (s SchemaCollection) PackageURL() string { - url := fmt.Sprintf("%s/%s", s.URL, s.Dir) - if strings.HasSuffix(url, "/") { - return url[:len(url)-1] - } - - return url -} - -func (s SchemaCollection) Gen() error { - g := generator.Generator{ - Dir: s.Dir, - Template: s, - } - - if err := g.Run(); err != nil { - return err - } - - for _, schema := range s.Schemas { - tmpl := SchemaTemplate{ - filename: schema.Name, - URL: s.PackageURL(), - Package: s.Package, - Schema: schema, - } - - g := generator.Generator{ - Dir: s.Dir, - Template: tmpl, - } - - if err := g.Run(); err != nil { - return err - } - } - - return nil -} - -func (s SchemaCollection) Filename() string { - return "shared" -} - -func (s SchemaCollection) Render() (string, error) { - str := "" - str += fmt.Sprintf("package %s\n", s.Package) - str += "\n" - str += "// this file is generated by gooo ORM. DON'T EDIT this file\n" - - sharedLibs := []string{ - "\"context\"", - "\"database/sql\"", - errorsPackage, - } - - if len(sharedLibs) > 0 { - str += fmt.Sprintf("import (\n%s\n)\n", strings.Join(sharedLibs, "\n")) - } - str += "\n" - - str += template.Interface{ - Name: "scanner", - Inters: []string{ - "Scan(dest ...any) error", - }, - }.String() - - str += template.Interface{ - Name: "queryer", - Inters: []string{ - "QueryRowContext(ctx context.Context, query string, dest ...any) *sql.Row", - "QueryContext(ctx context.Context, query string, dest ...any) (*sql.Rows, error)", - "ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)", - }, - }.String() - - str += "\n" - - // errors - str += `type NotFoundError struct {} - - func (e NotFoundError) Error() string { - return "record not found" - } - - var ErrNotFound = NotFoundError{}` - - str += "\n" - - str += `type PrimaryKeyMissingError struct {} - - func (e PrimaryKeyMissingError) Error() string { - return "primary key is required" - } - - var ErrPrimaryKeyMissing = PrimaryKeyMissingError{}` - - str += "\n" - - for _, schema := range s.Schemas { - str += fmt.Sprintf(`func New%s() *%s { - return &%s{ - Schema: schema.%sSchema, - } - } - `, schema.Name, schema.Name, schema.Name, schema.Name) - str += "\n" - - str += fmt.Sprintf(`func New%sWith(obj %s) *%s { - m := &%s{ - Schema: schema.%sSchema, - } - m.Assign(obj) - - return m - } - `, schema.Name, schema.Name, schema.Name, schema.Name, schema.Name) - str += "\n" - } - - return pretify(s.Filename(), str) -} diff --git a/pkg/schema/internal/template/template.go b/pkg/schema/internal/template/template.go deleted file mode 100644 index 0f396f7..0000000 --- a/pkg/schema/internal/template/template.go +++ /dev/null @@ -1,62 +0,0 @@ -package template - -import ( - "fmt" - "strings" -) - -func Pointer(name string) string { - return "*" + name -} - -type Method struct { - Receiver string - Name string - Args []Arg - ReturnTypes []string - Body string -} - -func (m Method) String() string { - return fmt.Sprintf( - "func (obj %s) %s(%s) (%s) {\n%s\n}\n\n", - m.Receiver, - m.Name, - stringifyArgs(m.Args), - strings.Join(m.ReturnTypes, ", "), - m.Body, - ) -} - -func stringifyArgs(args []Arg) string { - str := []string{} - for _, a := range args { - str = append(str, a.String()) - } - - return strings.Join(str, ", ") -} - -type Arg struct { - Name string - Type string -} - -func (a Arg) String() string { - return fmt.Sprintf("%s %s", a.Name, a.Type) -} - -type Interface struct { - Name string - Inters []string -} - -func (i Interface) String() string { - str := fmt.Sprintf("type %s interface {\n", i.Name) - for _, i := range i.Inters { - str += fmt.Sprintf("\t%s\n", i) - } - str += "}\n" - - return str -} diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go deleted file mode 100644 index 4325d49..0000000 --- a/pkg/schema/schema.go +++ /dev/null @@ -1,244 +0,0 @@ -package schema - -import ( - "fmt" - - "github.com/version-1/gooo/pkg/datasource/orm/validator" - gooostrings "github.com/version-1/gooo/pkg/strings" -) - -type SchemaFactory struct { - Primary Field - DefaultFields []Field -} - -func (d SchemaFactory) NewSchema(fields []Field) *Schema { - s := &Schema{} - s.Fields = []Field{d.Primary} - s.Fields = append(s.Fields, fields...) - s.Fields = append(s.Fields, d.DefaultFields...) - - return s -} - -type Schema struct { - Name string - TableName string - Fields []Field -} - -type SchemaType struct { - typeName string -} - -func (s SchemaType) String() string { - return s.typeName -} - -func (s *Schema) Type() SchemaType { - return SchemaType{s.Name} -} - -func (s *Schema) AddFields(fields ...Field) { - s.Fields = append(s.Fields, fields...) -} - -func (s *Schema) MutableColumns() []string { - fields := []string{} - for i := range s.Fields { - if s.Fields[i].IsMutable() { - fields = append(fields, gooostrings.ToSnakeCase(s.Fields[i].Name)) - } - } - - return fields -} - -func (s *Schema) ImmutableColumns() []string { - fields := []string{} - for i := range s.Fields { - if s.Fields[i].IsImmutable() { - fields = append(fields, gooostrings.ToSnakeCase(s.Fields[i].Name)) - } - } - - return fields -} - -func (s *Schema) SetClause() []string { - placeholders := []string{} - for i, c := range s.MutableColumns() { - placeholders = append(placeholders, fmt.Sprintf("%s = $%d", gooostrings.ToSnakeCase(c), i+1)) - } - - for _, c := range s.ImmutableColumns() { - if c == "updated_at" { - placeholders = append(placeholders, "updated_at = NOW()") - return placeholders - } - } - - return placeholders -} - -func (s *Schema) MutablePlaceholders() []string { - placeholders := []string{} - index := 1 - for i := range s.Fields { - if s.Fields[i].IsMutable() { - placeholders = append(placeholders, fmt.Sprintf("$%d", index)) - index++ - } - } - - return placeholders -} - -func (s *Schema) ImmutablePlaceholders() []string { - placeholders := []string{} - index := 1 - for i := range s.Fields { - if s.Fields[i].IsImmutable() { - placeholders = append(placeholders, fmt.Sprintf("$%d", index)) - index++ - } - } - - return placeholders -} - -func (s *Schema) IgnoredFields() []Field { - fields := []Field{} - for i := range s.Fields { - if s.Fields[i].Options.Ignore { - fields = append(fields, s.Fields[i]) - } - } - - return fields -} - -func (s *Schema) ColumnFields() []Field { - fields := []Field{} - for i := range s.Fields { - if !s.Fields[i].Options.Ignore { - fields = append(fields, s.Fields[i]) - } - } - - return fields -} - -func (s *Schema) Columns() []string { - fields := []string{} - for _, f := range s.ColumnFields() { - fields = append(fields, f.ColumnName()) - } - - return fields -} - -func (s *Schema) FieldNames() []string { - fields := []string{} - for i := range s.Fields { - fields = append(fields, s.Fields[i].Name) - } - - return fields -} - -func (s *Schema) MutableFields() []Field { - fields := []Field{} - for i := range s.Fields { - if s.Fields[i].IsMutable() { - fields = append(fields, s.Fields[i]) - } - } - - return fields -} - -func (s *Schema) MutableFieldKeys() []string { - fields := []string{} - for i := range s.Fields { - if s.Fields[i].IsMutable() { - fields = append(fields, gooostrings.ToSnakeCase(s.Fields[i].Name)) - } - } - - return fields -} - -func (s *Schema) AssociationFields() []Field { - fields := []Field{} - for i := range s.Fields { - if s.Fields[i].IsAssociation() { - fields = append(fields, s.Fields[i]) - } - } - - return fields -} - -func (s *Schema) PrimaryKey() string { - for i := range s.Fields { - if s.Fields[i].Options.PrimaryKey { - return s.Fields[i].Name - } - } - - return "" -} - -type Field struct { - Name string - Type FieldType - Tag string - Options FieldOptions -} - -func (f Field) String() string { - str := "" - field := fmt.Sprintf("\t%s %s", f.Name, f.Type) - if f.Tag != "" { - str = fmt.Sprintf("%s `%s`\n", field, f.Tag) - } else { - str = fmt.Sprintf("%s\n", field) - } - - return str -} - -func (f Field) ColumnName() string { - return gooostrings.ToSnakeCase(f.Name) -} - -func (f Field) IsMutable() bool { - return !f.Options.Immutable && !f.Options.Ignore -} - -func (f Field) IsImmutable() bool { - return f.Options.Immutable && !f.Options.Ignore -} - -func (f Field) IsAssociation() bool { - return f.Options.Association != nil -} - -type Validator struct { - Fields []string - Validate validator.Validator -} - -type Association struct { - Slice bool - Schema Schema -} - -type FieldOptions struct { - Immutable bool - PrimaryKey bool - Ignore bool // ignore fields for insert and update like fields of association. - Association *Association - Validators []Validator -} diff --git a/pkg/schema/serialize.go b/pkg/schema/serialize.go deleted file mode 100644 index a0c92a1..0000000 --- a/pkg/schema/serialize.go +++ /dev/null @@ -1,113 +0,0 @@ -package schema - -import ( - "fmt" - "strings" - - "github.com/version-1/gooo/pkg/schema/internal/template" - gooostrings "github.com/version-1/gooo/pkg/strings" -) - -func (s SchemaTemplate) defineToJSONAPIResource() string { - primaryKey := s.Schema.PrimaryKey() - - str := fmt.Sprintf(`includes := &jsonapi.Resources{} - r := jsonapi.Resource{ - ID: jsonapi.Stringify(obj.%s), - Type: "%s", - Attributes: obj, - Relationships: jsonapi.Relationships{}, - } - - `, primaryKey, gooostrings.ToSnakeCase(s.Schema.Name)) - - for _, field := range s.Schema.AssociationFields() { - t := fmt.Stringer(field.Type) - _, ok := t.(slice) - if v, ok := t.(Elementer); ok { - t = v.Element() - } - typeName := gooostrings.ToSnakeCase(t.String()) - association := field.Options.Association - primaryKey := association.Schema.PrimaryKey() - if ok { - str += fmt.Sprintf(` - relationships := jsonapi.RelationshipHasMany{} - for _, ele := range obj.%s { - relationships.Data = append( - relationships.Data, - jsonapi.ResourceIdentifier{ - ID: jsonapi.Stringify(ele.%s), - Type: "%s", - }, - ) - - resource, childIncludes := ele.ToJSONAPIResource() - includes.Append(resource) - includes.Append(childIncludes.Data...) - } - - if len(relationships.Data) > 0 { - r.Relationships["%s"] = relationships - } - `, field.Name, primaryKey, typeName, gooostrings.ToSnakeCase(field.Name)) - } else { - str += fmt.Sprintf(` - ele := obj.%s - if ele.%s == (%s{}).%s { - return r, *includes - } - relationship := jsonapi.Relationship{ - Data: jsonapi.ResourceIdentifier{ - ID: jsonapi.Stringify(ele.%s), - Type: "%s", - }, - } - - resource, childIncludes := ele.ToJSONAPIResource() - includes.Append(resource) - includes.Append(childIncludes.Data...) - - r.Relationships["%s"] = relationship - `, field.Name, primaryKey, t.String(), primaryKey, primaryKey, typeName, typeName) - } - } - - str += "return r, *includes" - - return template.Method{ - Receiver: s.Schema.Name, - Name: "ToJSONAPIResource", - Args: []template.Arg{}, - ReturnTypes: []string{"jsonapi.Resource", "jsonapi.Resources"}, - Body: str, - }.String() -} - -func (s SchemaTemplate) defineJSONAPISerialize() string { - fields := []string{} - for _, field := range s.Schema.ColumnFields() { - v := fmt.Sprintf( - `fmt.Sprintf("\"%s\": %s", jsonapi.Stringify(obj.%s))`, - gooostrings.ToSnakeCase(field.Name), - "%s", - field.Name, - ) - fields = append( - fields, - v, - ) - } - str := "lines := []string{\n" - str += strings.Join(fields, ", \n") + ",\n" - str += "}\n" - str += "return fmt.Sprintf(\"{\\n%s\\n}\", strings.Join(lines, \", \\n\")), nil" - - return template.Method{ - Receiver: s.Schema.Name, - Name: "JSONAPISerialize", - Args: []template.Arg{}, - ReturnTypes: []string{"string", "error"}, - Body: str, - }.String() -} diff --git a/pkg/schema/template.go b/pkg/schema/template.go deleted file mode 100644 index f59c208..0000000 --- a/pkg/schema/template.go +++ /dev/null @@ -1,288 +0,0 @@ -package schema - -import ( - "fmt" - "go/format" - "strings" - - "github.com/version-1/gooo/pkg/schema/internal/template" - "golang.org/x/tools/imports" -) - -type SchemaTemplate struct { - filename string - URL string - Package string - Schema Schema -} - -func (s SchemaTemplate) Filename() string { - return strings.ToLower(s.filename) -} - -func (s SchemaTemplate) Render() (string, error) { - str := "" - str += fmt.Sprintf("package %s\n", s.Package) - str += "\n" - - if len(s.libs()) > 0 { - str += fmt.Sprintf("import (\n%s\n)\n", strings.Join(s.libs(), "\n")) - } - str += "\n" - - // define model - str += s.defineModel() - - // columns - str += template.Method{ - Receiver: s.Schema.Name, - Name: "Columns", - Args: []template.Arg{}, - ReturnTypes: []string{"[]string"}, - Body: fmt.Sprintf( - "return []string{%s}", - strings.Join(wrapQuote(s.Schema.Columns()), ", "), - ), - }.String() - - // scan - scanFields := []string{} - for _, f := range s.Schema.ColumnFields() { - scanFields = append(scanFields, fmt.Sprintf("&obj.%s", f.Name)) - } - - receiver := template.Pointer(s.Schema.Name) - methods := []template.Method{ - { - Receiver: receiver, - Name: "Scan", - Args: []template.Arg{ - {Name: "rows", Type: "scanner"}, - }, - ReturnTypes: []string{"error"}, - Body: fmt.Sprintf(`if err := rows.Scan(%s); err != nil { - return err - } - - return nil`, - strings.Join(scanFields, ", "), - ), - }, - { - Receiver: receiver, - Name: "Destroy", - Args: []template.Arg{ - {Name: "ctx", Type: "context.Context"}, - {Name: "qr", Type: "queryer"}, - }, - ReturnTypes: []string{"error"}, - Body: fmt.Sprintf(`if obj.ID == uuid.Nil { - return ErrPrimaryKeyMissing - } - - query := "DELETE FROM %s WHERE id = $1" - if _, err := qr.ExecContext(ctx, query, obj.ID); err != nil { - return err - } - - return nil`, s.Schema.TableName), - }, - { - Receiver: receiver, - Name: "Find", - Args: []template.Arg{ - {Name: "ctx", Type: "context.Context"}, - {Name: "qr", Type: "queryer"}, - }, - ReturnTypes: []string{"error"}, - Body: fmt.Sprintf(`if obj.ID == uuid.Nil { - return ErrPrimaryKeyMissing - } - - query := "SELECT %s FROM %s WHERE id = $1" - row := qr.QueryRowContext(ctx, query, obj.ID) - - if err := obj.Scan(row); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return ErrNotFound - } - - return err - } - - return nil`, - strings.Join(s.Schema.Columns(), ", "), - s.Schema.TableName, - ), - }, - } - - for _, m := range methods { - str += m.String() - } - - str += s.defineSave() - str += s.defineAssign() - str += s.defineValidate() - str += s.defineJSONAPISerialize() - str += s.defineToJSONAPIResource() - - return pretify(s.Filename(), str) -} - -func (s SchemaTemplate) defineValidate() string { - fields := s.Schema.Fields - str := "" - index := 0 - for i, f := range fields { - for j, validator := range f.Options.Validators { - values := []string{ - "obj." + f.Name, - } - for _, v := range validator.Fields { - values = append(values, fmt.Sprintf("obj.%s", v)) - } - - if index == 0 { - str += fmt.Sprintf(`validator := obj.Schema.Fields[%d].Options.Validators[%d]`+"\n", i, j) - } else { - str += fmt.Sprintf(`validator = obj.Schema.Fields[%d].Options.Validators[%d]`+"\n", i, j) - } - str += fmt.Sprintf(`if err := validator.Validate("%s")(%s); err != nil { - return err - } - `+"\n\n", f.Name, strings.Join(values, ", ")) - index++ - } - } - - str += "return nil" - - return template.Method{ - Receiver: s.Schema.Name, - Name: "validate", - Args: []template.Arg{}, - ReturnTypes: []string{"goooerrors.ValidationError"}, - Body: str, - }.String() -} - -func (s SchemaTemplate) defineSave() string { - query := fmt.Sprintf(` - INSERT INTO %s (%s) VALUES ($1, $2, $3) - ON CONFLICT(id) DO UPDATE SET %s - RETURNING %s - `, - s.Schema.TableName, - strings.Join(s.Schema.MutableColumns(), ", "), - strings.Join(s.Schema.SetClause(), ", "), - strings.Join(s.Schema.Columns(), ", "), - ) - - mutableValues := []string{} - for _, f := range s.Schema.MutableFields() { - mutableValues = append(mutableValues, fmt.Sprintf("obj.%s", f.Name)) - } - - validateStr := `if err := obj.validate(); err != nil { - return err - } - ` - - return template.Method{ - Receiver: template.Pointer(s.Schema.Name), - Name: "Save", - Args: []template.Arg{ - {Name: "ctx", Type: "context.Context"}, - {Name: "qr", Type: "queryer"}, - }, - ReturnTypes: []string{"error"}, - Body: fmt.Sprintf( - validateStr+ - "query := `%s`\n"+` - row := qr.QueryRowContext(ctx, query, %s) - if err := obj.Scan(row); err != nil { - return err - } - - return nil`, - query, - strings.Join(mutableValues, ", "), - ), - }.String() -} - -func (s SchemaTemplate) defineAssign() string { - fields := []string{} - for _, f := range s.Schema.Fields { - fields = append(fields, fmt.Sprintf("obj.%s = v.%s", f.Name, f.Name)) - } - - return template.Method{ - Receiver: template.Pointer(s.Schema.Name), - Name: "Assign", - Args: []template.Arg{ - {Name: "v", Type: s.Schema.Name}, - }, - ReturnTypes: []string{}, - Body: strings.Join(fields, "\n"), - }.String() -} - -func (s SchemaTemplate) defineModel() string { - str := fmt.Sprintf("type %s struct {\n", s.Schema.Name) - str += "schema.Schema\n" - str += "// db related fields\n" - for _, f := range s.Schema.ColumnFields() { - str += f.String() - } - - str += "\n" - str += "// non-db related fields\n" - for _, f := range s.Schema.IgnoredFields() { - str += f.String() - } - str += "}\n" - str += "\n" - - return str -} - -func (s SchemaTemplate) libs() []string { - list := []string{ - schemaPackage, - errorsPackage, - stringsPackage, - jsonapiPackage, - "\"github.com/google/uuid\"", - "\"strings\"", - "\"time\"", - "\"fmt\"", - // fmt.Sprintf("schema \"%s/schema\"", s.URL), - } - - return list -} - -func wrapQuote(list []string) []string { - for i := range list { - list[i] = fmt.Sprintf("\"%s\"", list[i]) - } - - return list -} - -func pretify(filename, s string) (string, error) { - // return s, nil - formatted, err := format.Source([]byte(s)) - if err != nil { - return s, err - } - - processed, err := imports.Process(filename, formatted, nil) - if err != nil { - return string(formatted), err - } - - return string(processed), err -} diff --git a/pkg/schema/type.go b/pkg/schema/type.go deleted file mode 100644 index 560a02a..0000000 --- a/pkg/schema/type.go +++ /dev/null @@ -1,56 +0,0 @@ -package schema - -import "fmt" - -type FieldType fmt.Stringer - -type Elementer interface { - Element() FieldType -} - -type FieldValueType string - -func (f FieldValueType) String() string { - return string(f) -} - -const ( - String FieldValueType = "string" - Int FieldValueType = "int" - Bool FieldValueType = "bool" - Byte FieldValueType = "byte" - Time FieldValueType = "time.Time" - UUID FieldValueType = "uuid.UUID" -) - -type ref struct { - Type FieldType -} - -func (p ref) String() string { - return fmt.Sprintf("*%s", p.Type) -} - -func (p ref) Element() FieldType { - return p.Type -} - -func Ref(f FieldType) ref { - return ref{Type: f} -} - -type slice struct { - Type FieldType -} - -func (s slice) String() string { - return fmt.Sprintf("[]%s", s.Type) -} - -func (s slice) Element() FieldType { - return s.Type -} - -func Slice(f FieldType) slice { - return slice{Type: f} -} diff --git a/pkg/testing/table.go b/pkg/testing/table.go deleted file mode 100644 index 7603f83..0000000 --- a/pkg/testing/table.go +++ /dev/null @@ -1 +0,0 @@ -package testing diff --git a/pkg/auth/auth.go b/pkg/toolkit/auth/auth.go similarity index 59% rename from pkg/auth/auth.go rename to pkg/toolkit/auth/auth.go index 45952fd..5647555 100644 --- a/pkg/auth/auth.go +++ b/pkg/toolkit/auth/auth.go @@ -1,19 +1,19 @@ package auth import ( + "encoding/json" + "net/http" "os" "strings" "time" jwt "github.com/golang-jwt/jwt/v5" - "github.com/version-1/gooo/pkg/controller" - "github.com/version-1/gooo/pkg/http/request" - "github.com/version-1/gooo/pkg/http/response" + "github.com/version-1/gooo/pkg/core/api/middleware" ) type JWTAuth[T any] struct { - If func(r *request.Request) bool - OnAuthorized func(r *request.Request, sub string) error + If func(r *http.Request) bool + OnAuthorized func(r *http.Request, sub string) error PrivateKey *string TokenExpiresIn time.Duration Issuer string @@ -27,7 +27,7 @@ func (a JWTAuth[T]) GetPrivateKey() string { return *a.PrivateKey } -func (a JWTAuth[T]) Sign(r *request.Request) (string, error) { +func (a JWTAuth[T]) Sign(r *http.Request) (string, error) { claims := &jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(a.TokenExpiresIn)), Issuer: a.Issuer, @@ -37,10 +37,10 @@ func (a JWTAuth[T]) Sign(r *request.Request) (string, error) { return token.SignedString(a.GetPrivateKey()) } -func (a JWTAuth[T]) Guard() controller.Middleware { - return controller.Middleware{ +func (a JWTAuth[T]) Guard() middleware.Middleware { + return middleware.Middleware{ If: a.If, - Do: func(w *response.Response, r *request.Request) bool { + Do: func(w http.ResponseWriter, r *http.Request) bool { str := r.Header.Get("Authorization") token := strings.TrimSpace(strings.ReplaceAll(str, "Bearer ", "")) t, err := jwt.ParseWithClaims(token, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) { @@ -58,11 +58,11 @@ func (a JWTAuth[T]) Guard() controller.Middleware { } if expired { - w.Unauthorized().JSON(map[string]string{ + renderJSON(w, map[string]string{ "code": "auth:token_expired", "error": "Unauthorized", "detail": err.Error(), - }) + }, http.StatusUnauthorized) return false } @@ -82,12 +82,19 @@ func (a JWTAuth[T]) Guard() controller.Middleware { } } -func reportError(w *response.Response, e error) { - w.Unauthorized().JSON( - map[string]string{ - "code": "unauthorized", - "error": "Unauthorized", - "detail": e.Error(), - }, - ) +func renderJSON(w http.ResponseWriter, payload map[string]string, status int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(payload) +} + +func reportError(w http.ResponseWriter, e error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + payload := map[string]string{ + "code": "unauthorized", + "error": "Unauthorized", + "detail": e.Error(), + } + json.NewEncoder(w).Encode(payload) } diff --git a/pkg/auth/error.go b/pkg/toolkit/auth/error.go similarity index 100% rename from pkg/auth/error.go rename to pkg/toolkit/auth/error.go diff --git a/pkg/toolkit/auth/helper.go b/pkg/toolkit/auth/helper.go new file mode 100644 index 0000000..a9448a7 --- /dev/null +++ b/pkg/toolkit/auth/helper.go @@ -0,0 +1,17 @@ +package auth + +import ( + "net/http" + + "github.com/version-1/gooo/pkg/core/api/context" +) + +func SetContextOnAuthorized[T any](r *http.Request, sub string, fetcher func(sub string) (T, error)) error { + u, err := fetcher(sub) + if err != nil { + return err + } + + r.WithContext(context.With(r.Context(), "user", u)) + return nil +} diff --git a/pkg/auth/validate.go b/pkg/toolkit/auth/validate.go similarity index 100% rename from pkg/auth/validate.go rename to pkg/toolkit/auth/validate.go diff --git a/pkg/errors/errors.go b/pkg/toolkit/errors/errors.go similarity index 75% rename from pkg/errors/errors.go rename to pkg/toolkit/errors/errors.go index bfabf2e..577f8c7 100644 --- a/pkg/errors/errors.go +++ b/pkg/toolkit/errors/errors.go @@ -11,6 +11,17 @@ type Error struct { stack *stack } +func Wrap(err error) *Error { + if err == nil { + return nil + } + + return &Error{ + err: err, + stack: captureStack(), + } +} + func New(msg string) *Error { return &Error{ err: errors.New(msg), @@ -18,6 +29,13 @@ func New(msg string) *Error { } } +func Errorf(tmpl string, args ...any) *Error { + return &Error{ + err: errors.New(fmt.Sprintf(tmpl, args...)), + stack: captureStack(), + } +} + func (e Error) StackTrace() string { return fmt.Sprintf("%+v", e.stack) } @@ -30,6 +48,8 @@ func (e Error) Format(f fmt.State, c rune) { switch c { case 'v': if f.Flag('+') { + fmt.Fprintf(f, "%s\n", e.Error()) + fmt.Fprintln(f, "") fmt.Fprintf(f, "%+v\n", e.stack) return } else { @@ -87,7 +107,7 @@ func (f frame) String() string { return "" } - return fmt.Sprintf("%s:%s:%d", f.File(), f.FuncName(), f.Line()) + return fmt.Sprintf("%s. method: %s. line: %d", f.File(), f.FuncName(), f.Line()) } func (f *frame) File() string { @@ -109,13 +129,21 @@ func (f *frame) Line() int { } func (f *frame) FuncName() string { + n := func(s string) string { + for i := len(s) - 1; i > 0; i-- { + if s[i] == '.' { + return s[i+1:] + } + } + return s + } if f.name != nil { - return *f.name + return n(*f.name) } f.collect() - return *f.name + return n(*f.name) } func captureStack() *stack { diff --git a/pkg/toolkit/errors/errors_test.go b/pkg/toolkit/errors/errors_test.go new file mode 100644 index 0000000..56b5e7d --- /dev/null +++ b/pkg/toolkit/errors/errors_test.go @@ -0,0 +1,101 @@ +package errors + +import ( + "fmt" + "strings" + "testing" + + goootesting "github.com/version-1/gooo/pkg/toolkit/testing" +) + +func TestErrors(t *testing.T) { + err := New("msg") + + test := goootesting.NewTable([]goootesting.Record[string, []string]{ + { + Name: "Stacktrace", + Subject: func(_t *testing.T) (string, error) { + return err.StackTrace(), nil + }, + Expect: func(t *testing.T) ([]string, error) { + return []string{ + "gooo/pkg/toolkit/errors/errors_test.go. method: TestErrors. line: 12", + "src/testing/testing.go. method: tRunner. line: 1689", + "src/runtime/asm_amd64.s. method: goexit. line: 1695", + "", + }, nil + }, + Assert: func(t *testing.T, r *goootesting.Record[string, []string]) bool { + e, _ := r.Expect(t) + s, _ := r.Subject(t) + lines := strings.Split(s, "\n") + for i, line := range lines { + if !strings.HasSuffix(line, e[i]) { + t.Errorf("Expected(line %d) %s to contain %s", i, line, e[i]) + return false + } + } + return true + }, + }, + { + Name: "Print Error with +v", + Subject: func(_t *testing.T) (string, error) { + return fmt.Sprintf("%+v", err), nil + }, + Expect: func(t *testing.T) ([]string, error) { + return []string{ + "pkg/errors : msg", + "", + "gooo/pkg/toolkit/errors/errors_test.go. method: TestErrors. line: 12", + "src/testing/testing.go. method: tRunner. line: 1689", + "src/runtime/asm_amd64.s. method: goexit. line: 1695", + "", + "", + }, nil + }, + Assert: func(t *testing.T, r *goootesting.Record[string, []string]) bool { + e, _ := r.Expect(t) + s, _ := r.Subject(t) + lines := strings.Split(s, "\n") + for i, line := range lines { + if !strings.HasSuffix(line, e[i]) { + t.Errorf("Expected(line %d) %s to contain %s", i, line, e[i]) + return false + } + } + return true + }, + }, + { + Name: "Print Error with v", + Subject: func(_t *testing.T) (string, error) { + return fmt.Sprintf("%v", err), nil + }, + Expect: func(t *testing.T) ([]string, error) { + return []string{"pkg/errors : msg"}, nil + }, + Assert: func(t *testing.T, r *goootesting.Record[string, []string]) bool { + e, _ := r.Expect(t) + s, _ := r.Subject(t) + return s == e[0] + }, + }, + { + Name: "Print Error with s", + Subject: func(_t *testing.T) (string, error) { + return fmt.Sprintf("%s", err), nil + }, + Expect: func(t *testing.T) ([]string, error) { + return []string{"pkg/errors : msg"}, nil + }, + Assert: func(t *testing.T, r *goootesting.Record[string, []string]) bool { + e, _ := r.Expect(t) + s, _ := r.Subject(t) + return s == e[0] + }, + }, + }) + + test.Run(t) +} diff --git a/pkg/http/client/client.go b/pkg/toolkit/httpclient/client.go similarity index 89% rename from pkg/http/client/client.go rename to pkg/toolkit/httpclient/client.go index 21062d4..90bd4bc 100644 --- a/pkg/http/client/client.go +++ b/pkg/toolkit/httpclient/client.go @@ -10,6 +10,8 @@ import ( "time" ) +const pkgName = "pkg/http/client" + type Config struct { BaseURI string Token string @@ -19,6 +21,7 @@ type Config struct { type Logger interface { Infof(string, ...any) + Debugf(string, ...any) } type Client struct { @@ -106,7 +109,7 @@ func (r *Request) UnmarshalBody(res *http.Response, v any) error { return err } - r.Logger().Infof("response body: %s", string(buf)) + r.Logger().Debugf("%s: response body: %s", pkgName, string(buf)) if err = json.Unmarshal(buf, &v); err != nil { return err @@ -131,8 +134,8 @@ func Do[K, V any](ctx context.Context, d Doer, body *K, response *V) error { return err } - d.Logger().Infof("request uri: %s, method: %s", d.RequestURI(), d.Method()) - d.Logger().Infof("request body: %s", string(b)) + d.Logger().Debugf("%s: request uri: %s, method: %s", pkgName, d.RequestURI(), d.Method()) + d.Logger().Debugf("%s: request body: %s", pkgName, string(b)) var reqBody io.Reader = nil if body != nil && (d.Method() == http.MethodPost || d.Method() == http.MethodPut || d.Method() == http.MethodPatch) { @@ -157,7 +160,7 @@ func Do[K, V any](ctx context.Context, d Doer, body *K, response *V) error { if err != nil { return err } - d.Logger().Infof("response status: %s", res.Status) + d.Logger().Debugf("%s: response status: %s", pkgName, res.Status) if err := d.UnmarshalBody(res, response); err != nil { return err diff --git a/pkg/logger/logger.go b/pkg/toolkit/logger/logger.go similarity index 74% rename from pkg/logger/logger.go rename to pkg/toolkit/logger/logger.go index 8b687ca..11cbed6 100644 --- a/pkg/logger/logger.go +++ b/pkg/toolkit/logger/logger.go @@ -12,9 +12,23 @@ type Logger interface { Fatalf(format string, args ...interface{}) } -type defaultLogger struct{} +type defaultLogger struct { + level LogLevel +} + +type LogLevel int -var DefaultLogger = defaultLogger{} +const ( + LogLevelDebug LogLevel = iota + LogLevelInfo + LogLevelWarn + LogLevelError + LogLevelFatal +) + +var DefaultLogger = defaultLogger{ + level: LogLevelInfo, +} func InfoLabel() string { return WithColor(Cyan, "[INFO]") @@ -36,6 +50,10 @@ func DateFormat(t time.Time) string { return WithColor(Gray, t.Format("2006-01-02T15:04:05 -0700")) } +func (l *defaultLogger) SetLevel(level LogLevel) { + l.level = level +} + func (l defaultLogger) SInfof(format string, args ...any) string { return fmt.Sprintf(fmt.Sprintf("%s %s %s\n", InfoLabel(), DateLabel(), format), args...) } @@ -48,19 +66,38 @@ func (l defaultLogger) SWarnf(format string, args ...any) string { return fmt.Sprintf(fmt.Sprintf("%s %s %s\n", WarnLabel(), DateLabel(), format), args...) } +func (l defaultLogger) Debugf(format string, args ...interface{}) { + if l.level >= LogLevelInfo { + return + } + fmt.Printf(l.SInfof(format, args...)) +} + func (l defaultLogger) Infof(format string, args ...interface{}) { + if l.level > LogLevelInfo { + return + } fmt.Printf(l.SInfof(format, args...)) } func (l defaultLogger) Errorf(format string, args ...interface{}) { + if l.level > LogLevelError { + return + } fmt.Printf(l.SErrorf(format, args...)) } func (l defaultLogger) Warnf(format string, args ...interface{}) { + if l.level > LogLevelWarn { + return + } fmt.Printf(l.SWarnf(format, args...)) } func (l defaultLogger) Fatalf(format string, args ...interface{}) { + if l.level > LogLevelFatal { + return + } log.Fatalf(l.SErrorf(format, args...)) } diff --git a/pkg/toolkit/middleware/middleware.go b/pkg/toolkit/middleware/middleware.go new file mode 100644 index 0000000..8bfec18 --- /dev/null +++ b/pkg/toolkit/middleware/middleware.go @@ -0,0 +1,133 @@ +package middleware + +import ( + "bytes" + "fmt" + "io" + "net/http" + "strings" + + "github.com/version-1/gooo/pkg/core/api/middleware" + "github.com/version-1/gooo/pkg/toolkit/logger" +) + +func RequestLogger(logger logger.Logger) middleware.Middleware { + return middleware.Middleware{ + Name: "RequestLogger", + If: middleware.Always, + Do: func(w http.ResponseWriter, r *http.Request) bool { + logger.Infof("%s %s", r.Method, r.URL.Path) + return true + }, + } +} + +func ResponseLogger(logger logger.Logger) middleware.Middleware { + return middleware.Middleware{ + Name: "ResponseLogger", + If: middleware.Always, + Do: func(w http.ResponseWriter, r *http.Request) bool { + // FIXME: get stats code + // logger.Infof("Status: %d", w.StatusCode()) + return true + }, + } +} + +func RequestBodyLogger(logger logger.Logger) middleware.Middleware { + return middleware.Middleware{ + Name: "RequestBodyLogger", + If: func(r *http.Request) bool { + return r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch + }, + Do: func(w http.ResponseWriter, r *http.Request) bool { + b, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal server error")) + logger.Errorf("Error reading request body: %v", err) + return false + } + + if len(b) > 0 { + logger.Infof("body: %s", b) + } + + r.Body.Close() + r.Body = io.NopCloser(bytes.NewReader(b)) + + return true + }, + } +} + +func RequestHeaderLogger(logger logger.Logger) middleware.Middleware { + return middleware.Middleware{ + Name: "RequestHeaderLogger", + If: middleware.Always, + Do: func(w http.ResponseWriter, r *http.Request) bool { + logger.Infof("HTTP Headers: ") + for k, v := range r.Header { + logger.Infof("%s: %s", k, v) + } + return true + }, + } +} + +func CORS(origin, methods, headers []string) middleware.Middleware { + return middleware.Middleware{ + Name: "CORS", + If: middleware.Always, + Do: func(w http.ResponseWriter, r *http.Request) bool { + w.Header().Set("Access-Control-Allow-Origin", strings.Join(origin, ", ")) + w.Header().Set("Access-Control-Allow-Methods", strings.Join(methods, ", ")) + w.Header().Set("Access-Control-Allow-Headers", strings.Join(headers, ", ")) + return true + }, + } +} + +func WithContext(callbacks ...func(r *http.Request) *http.Request) middleware.Middleware { + return middleware.Middleware{ + Name: "WithContext", + If: middleware.Always, + Do: func(w http.ResponseWriter, r *http.Request) bool { + for _, cb := range callbacks { + req := cb(r) + *r = *req + } + + return true + }, + } +} + +type Handler interface { + fmt.Stringer + Match(r *http.Request) bool + Handler(w http.ResponseWriter, r *http.Request) +} + +func RequestHandler(handlers []Handler) middleware.Middleware { + return middleware.Middleware{ + Name: "RequestHandler", + If: middleware.Always, + Do: func(w http.ResponseWriter, r *http.Request) bool { + match := false + for _, handler := range handlers { + if handler.Match(r) { + handler.Handler(w, r) + match = true + break + } + } + if !match { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(fmt.Sprintf("Not found endpoint: %s", r.URL.Path))) + } + + return match + }, + } +} diff --git a/pkg/toolkit/middleware/middleware_test.go b/pkg/toolkit/middleware/middleware_test.go new file mode 100644 index 0000000..c870d7c --- /dev/null +++ b/pkg/toolkit/middleware/middleware_test.go @@ -0,0 +1 @@ +package middleware diff --git a/pkg/payload/loader.go b/pkg/toolkit/payload/loader.go similarity index 59% rename from pkg/payload/loader.go rename to pkg/toolkit/payload/loader.go index b18ed38..8e242f0 100644 --- a/pkg/payload/loader.go +++ b/pkg/toolkit/payload/loader.go @@ -2,13 +2,33 @@ package payload import ( "bufio" + "fmt" "os" "strings" ) +type EnvVarsLoader[T fmt.Stringer] struct { + keys []T +} + +func NewEnvVarsLoader[T fmt.Stringer](keys []T) *EnvVarsLoader[T] { + return &EnvVarsLoader[T]{ + keys: keys, + } +} + +func (l *EnvVarsLoader[T]) Load() (*map[string]any, error) { + m := &map[string]any{} + for _, k := range l.keys { + s := k.String() + (*m)[s] = os.Getenv(s) + } + + return m, nil +} + type EnvfileLoader[T comparable] struct { - path string - keyMaps map[string]T + path string } func NewEnvfileLoader[T comparable](path string) *EnvfileLoader[T] { @@ -38,8 +58,9 @@ func (l *EnvfileLoader[T]) Load() (*map[string]any, error) { if strings.Contains(line, "=") { parts := strings.Split(line, "=") - if len(parts) == 2 { - v := strings.TrimSpace(strings.TrimSuffix(parts[1], "\n")) + if len(parts) >= 2 { + str := strings.Join(parts[1:], "=") + v := strings.TrimSpace(strings.TrimSuffix(str, "\n")) os.Setenv(parts[0], v) (*m)[parts[0]] = v } diff --git a/pkg/toolkit/payload/loader_test.go b/pkg/toolkit/payload/loader_test.go new file mode 100644 index 0000000..3dfb763 --- /dev/null +++ b/pkg/toolkit/payload/loader_test.go @@ -0,0 +1,38 @@ +package payload + +import ( + "testing" +) + +type ConfigKey string + +const ( + PORT ConfigKey = "PORT" + DATABAE_URL ConfigKey = "DATABASE_URL" +) + +func TestLoad(t *testing.T) { + t.Skip("skipping test in CI") + loader := NewEnvfileLoader[ConfigKey]("./fixtures/.env.test") + m, err := loader.Load() + if err != nil { + t.Fatal(err) + } + + i := 0 + for k, v := range *m { + if k == "PORT" && v != "3000" { + t.Fatalf("expected %s, got %s", "3000", v) + } + + if k == "DATABASE_URL" && v != "postgres://postgres:password@localhost:5432/test?sslmode=disable" { + t.Fatalf("expected %s, got %s", "postgres://postgres:password@localhost:5432/test?sslmode=disabled", v) + } + + i++ + } + + if i != 3 { + t.Fatalf("expected %d, got %d", 3, i) + } +} diff --git a/pkg/payload/payload.go b/pkg/toolkit/payload/payload.go similarity index 100% rename from pkg/payload/payload.go rename to pkg/toolkit/payload/payload.go diff --git a/pkg/presenter/jsonapi/error.go b/pkg/toolkit/presenter/jsonapi/error.go similarity index 55% rename from pkg/presenter/jsonapi/error.go rename to pkg/toolkit/presenter/jsonapi/error.go index 8ed013f..2369fc5 100644 --- a/pkg/presenter/jsonapi/error.go +++ b/pkg/toolkit/presenter/jsonapi/error.go @@ -18,12 +18,17 @@ func (j Errors) Error() string { func (j Errors) JSONAPISerialize() (string, error) { str := "[" - for _, e := range j { + for i, e := range j { json, err := e.JSONAPISerialize() if err != nil { return "", err } - str += json + "," + + comma := "" + if i != len(j)-1 { + comma = "," + } + str += json + comma } str += "]" @@ -43,12 +48,37 @@ func (j Error) Error() string { } func (j Error) JSONAPISerialize() (string, error) { + id, err := Escape(j.ID) + if err != nil { + return "", err + } + + status, err := Escape(j.Status) + if err != nil { + return "", err + } + + code, err := Escape(j.Code) + if err != nil { + return "", err + } + + title, err := Escape(j.Title) + if err != nil { + return "", err + } + + detail, err := Escape(j.Detail) + if err != nil { + return "", err + } + fields := []string{ - fmt.Sprintf("\"id\": %s", Stringify(j.ID)), - fmt.Sprintf("\"status\": %s", Stringify(j.Status)), - fmt.Sprintf("\"code\": %s", Stringify(j.Code)), - fmt.Sprintf("\"title\": %s", Stringify(j.Title)), - fmt.Sprintf("\"detail\": %s", Stringify(j.Detail)), + fmt.Sprintf("\"id\": %s", id), + fmt.Sprintf("\"status\": %s", status), + fmt.Sprintf("\"code\": %s", code), + fmt.Sprintf("\"title\": %s", title), + fmt.Sprintf("\"detail\": %s", detail), } return fmt.Sprintf("{\n%s\n}", strings.Join(fields, ", \n")), nil diff --git a/pkg/presenter/jsonapi/helper.go b/pkg/toolkit/presenter/jsonapi/helper.go similarity index 67% rename from pkg/presenter/jsonapi/helper.go rename to pkg/toolkit/presenter/jsonapi/helper.go index 79ba15b..5e3c4d7 100644 --- a/pkg/presenter/jsonapi/helper.go +++ b/pkg/toolkit/presenter/jsonapi/helper.go @@ -1,59 +1,11 @@ package jsonapi import ( - "encoding/json" "net/http" "github.com/google/uuid" ) -type Attributes[T any] struct { - v T -} - -func NewAttributes[T any](v T) Attributes[T] { - return Attributes[T]{v} -} - -func (v Attributes[T]) JSONAPISerialize() (string, error) { - if b, err := json.Marshal(v); err != nil { - return "", err - } else { - return string(b), nil - } -} - -type Resourcable interface { - ID() string - Type() string - Resources() Resources -} - -type ResourceTemplate struct { - Target Resourcable -} - -func (v ResourceTemplate) ToJSONAPIResource() (data Resource, included Resources) { - t := v.Target - return Resource{ - ID: t.ID(), - Type: t.Type(), - Attributes: NewAttributes(v), - }, t.Resources() -} - -type resourcerer interface { - Resourcer() Resourcer -} - -func ToResourcerList[T resourcerer](list []T) []Resourcer { - resources := make([]Resourcer, 0, len(list)) - for _, r := range list { - resources = append(resources, r.Resourcer()) - } - return resources -} - type CodeGetter interface { Code() string } @@ -95,7 +47,7 @@ func (e ErrorResponse) Error() string { func (e ErrorResponse) ToJSONAPIError() Error { return Error{ ID: uuid.New().String(), - Title: "Internal Server Error", + Title: e.Title(), Status: http.StatusInternalServerError, Code: e.Code(), Detail: e.Error(), @@ -161,3 +113,17 @@ func NewForbidden(err error) Error { return e } + +type Resourcers []Resourcer + +func (r Resourcers) ToJSONAPIResource() (Resources, Resources) { + list := Resources{} + includes := Resources{} + for _, ele := range r { + re, appending := ele.ToJSONAPIResource() + list.Append(re) + includes.Append(appending.Data...) + } + + return list, includes +} diff --git a/pkg/presenter/jsonapi/jsonapi.go b/pkg/toolkit/presenter/jsonapi/jsonapi.go similarity index 52% rename from pkg/presenter/jsonapi/jsonapi.go rename to pkg/toolkit/presenter/jsonapi/jsonapi.go index 4d36b55..d97eb27 100644 --- a/pkg/presenter/jsonapi/jsonapi.go +++ b/pkg/toolkit/presenter/jsonapi/jsonapi.go @@ -6,6 +6,9 @@ import ( "fmt" "sort" "strings" + + goooerrors "github.com/version-1/gooo/pkg/toolkit/errors" + "github.com/version-1/gooo/pkg/toolkit/logger" ) type Resourcer interface { @@ -19,15 +22,19 @@ type Root[T Serializer] struct { Included Resources } -func New(data Resource, includes Resources, meta Serializer) *Root[Resource] { +func New(data Resource, includes Resources, meta Serializer) (*Root[Resource], error) { + if data.ID == "" { + return nil, goooerrors.Errorf("ID is required.") + } + return &Root[Resource]{ Data: data, Meta: meta, Included: includes, - } + }, nil } -func NewMany(data Resources, includes Resources, meta Serializer) *Root[Resources] { +func newMany(data Resources, includes Resources, meta Serializer) *Root[Resources] { return &Root[Resources]{ Data: data, Meta: meta, @@ -35,16 +42,21 @@ func NewMany(data Resources, includes Resources, meta Serializer) *Root[Resource } } -func NewManyFrom[T Resourcer](list []T, meta Serializer) *Root[Resources] { - includes := &Resources{} +func NewManyFrom[T Resourcer](list []T, meta Serializer) (*Root[Resources], error) { + includes := &Resources{ + ShouldSort: true, + } resources := &Resources{} - for _, ele := range list { + for index, ele := range list { r, childIncludes := ele.ToJSONAPIResource() + if r.ID == "" { + return nil, goooerrors.Errorf("ID is required. index: %d", index) + } resources.Append(r) includes.Append(childIncludes.Data...) } - return NewMany(*resources, *includes, meta) + return newMany(*resources, *includes, meta), nil } func NewErrors(errors Errors) *Root[Nil] { @@ -59,34 +71,43 @@ func (j Root[T]) Serialize() (string, error) { data, err := j.Data.JSONAPISerialize() if err != nil { - return "", err + return "", goooerrors.Wrap(err) } fields = append(fields, fmt.Sprintf("\"data\": %s", data)) if j.Meta != nil { meta, err := j.Meta.JSONAPISerialize() if err != nil { - return "", err + return "", goooerrors.Wrap(err) } fields = append(fields, fmt.Sprintf("\"meta\": %s", meta)) } errors, err := j.Errors.JSONAPISerialize() if err != nil { - return "", err + return "", goooerrors.Wrap(err) } - fields = append(fields, fmt.Sprintf("\"errors\": %s", errors)) + + if errors != "[]" { + fields = append(fields, fmt.Sprintf("\"errors\": %s", errors)) + } + included, err := j.Included.JSONAPISerialize() if err != nil { - return "", err + return "", goooerrors.Wrap(err) + } + + if included != "[]" { + fields = append(fields, fmt.Sprintf("\"included\": %s", included)) } - fields = append(fields, fmt.Sprintf("\"included\": %s", included)) s := fmt.Sprintf("{\n%s\n}", strings.Join(fields, ", \n")) var out bytes.Buffer - if err := json.Indent(&out, []byte(s), "", "\t"); err != nil { - return "", err + if err := json.Compact(&out, []byte(s)); err != nil { + logger.DefaultLogger.Errorf("pkg/presenter/jsonapi: got error on compact json. %s") + logger.DefaultLogger.Errorf("%s\n", s) + return "", goooerrors.Wrap(err) } return out.String(), nil @@ -106,20 +127,36 @@ type Serializers []Serializer func (s Serializers) JSONAPISerialize() (string, error) { str := "[" - for _, s := range s { - json, err := s.JSONAPISerialize() + for i, serializer := range s { + json, err := serializer.JSONAPISerialize() if err != nil { - return "", err + return "", goooerrors.Wrap(err) } - str += json + "," + + comma := "" + if i != len(s)-1 { + comma = "," + } + str += json + comma } str += "]" return str, nil } -type Resources struct { - Data []Resource - keyMap map[string]bool +type Attributes[T any] struct { + v T +} + +func NewAttributes[T any](v T) Attributes[T] { + return Attributes[T]{v} +} + +func (a Attributes[T]) JSONAPISerialize() (string, error) { + if b, err := json.Marshal(a.v); err != nil { + return "", err + } else { + return string(b), nil + } } type resourceList []Resource @@ -134,6 +171,12 @@ func (r resourceList) Less(i, j int) bool { } func (r resourceList) Swap(i, j int) { r[i], r[j] = r[j], r[i] } +type Resources struct { + Data []Resource + keyMap map[string]bool + ShouldSort bool +} + func (j *Resources) Append(r ...Resource) { if j.keyMap == nil { j.keyMap = make(map[string]bool) @@ -147,7 +190,9 @@ func (j *Resources) Append(r ...Resource) { } } - sort.Sort(resourceList(j.Data)) + if j.ShouldSort { + sort.Sort(resourceList(j.Data)) + } } func (j Resources) JSONAPISerialize() (string, error) { @@ -176,20 +221,45 @@ type Resource struct { func (j Resource) JSONAPISerialize() (string, error) { attrs, err := j.Attributes.JSONAPISerialize() if err != nil { - return "", err + return "", goooerrors.Wrap(err) } r, err := j.Relationships.JSONAPISerialize() if err != nil { - return "", err + return "", goooerrors.Wrap(err) } - return `{ - "id": ` + j.ID + `, - "type": "` + j.Type + `", - "attributes": ` + attrs + `, - "relationships": ` + r + ` - }`, nil + list := []string{} + + id, err := Escape(j.ID) + if err != nil { + return "", goooerrors.Wrap(err) + } + + t, err := Escape(j.Type) + if err != nil { + return "", goooerrors.Wrap(err) + } + list = append(list, fmt.Sprintf(`"id": %s`, id)) + list = append(list, fmt.Sprintf(`"type": %s`, t)) + list = append(list, fmt.Sprintf(`"attributes": %s`, attrs)) + if r != "{}" { + list = append(list, fmt.Sprintf(`"relationships": %s`, r)) + } + + return "{\n" + strings.Join(list, ", \n") + "\n}", nil +} + +func roundQuotes(s string) string { + if len(s) < 1 { + return s + } + + if s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + + return s } type Relationships map[string]Serializer @@ -199,7 +269,7 @@ func (j Relationships) JSONAPISerialize() (string, error) { for k, r := range j { json, err := r.JSONAPISerialize() if err != nil { - return "", err + return "", goooerrors.Wrap(err) } lines = append(lines, "\""+k+"\": "+json) } @@ -218,7 +288,7 @@ func (j RelationshipHasMany) JSONAPISerialize() (string, error) { for i := range j.Data { json, err := j.Data[i].JSONAPISerialize() if err != nil { - return "", err + return "", goooerrors.Wrap(err) } lines = append(lines, json) @@ -239,7 +309,7 @@ type Relationship struct { func (j Relationship) JSONAPISerialize() (string, error) { json, err := j.Data.JSONAPISerialize() if err != nil { - return "", err + return "", goooerrors.Wrap(err) } return `{ @@ -253,9 +323,18 @@ type ResourceIdentifier struct { } func (j ResourceIdentifier) JSONAPISerialize() (string, error) { + id, err := Escape(j.ID) + if err != nil { + return "", goooerrors.Wrap(err) + } + + t, err := Escape(j.Type) + if err != nil { + return "", goooerrors.Wrap(err) + } return `{ - "id": ` + j.ID + `, - "type": "` + j.Type + `" + "id": ` + id + `, + "type": ` + t + ` }`, nil } @@ -264,3 +343,42 @@ type Nil struct{} func (n Nil) JSONAPISerialize() (string, error) { return "null", nil } + +func HasOne(r *Resource, includes *Resources, ele Resourcer, id any, typeName string) { + if ele == nil { + return + } + + r.Relationships[typeName] = Relationship{ + Data: ResourceIdentifier{ + ID: Stringify(id), + Type: typeName, + }, + } + + resource, childIncludes := ele.ToJSONAPIResource() + includes.Append(resource) + includes.Append(childIncludes.Data...) +} + +func HasMany(r *Resource, includes *Resources, elements []Resourcer, typeName string, cb func(ri *ResourceIdentifier, index int)) { + relationships := RelationshipHasMany{} + for i, ele := range elements { + ri := ResourceIdentifier{ + Type: typeName, + } + cb(&ri, i) + relationships.Data = append( + relationships.Data, + ri, + ) + + resource, childIncludes := ele.ToJSONAPIResource() + includes.Append(resource) + includes.Append(childIncludes.Data...) + } + + if len(relationships.Data) > 0 { + r.Relationships[typeName] = relationships + } +} diff --git a/pkg/toolkit/presenter/jsonapi/stringify.go b/pkg/toolkit/presenter/jsonapi/stringify.go new file mode 100644 index 0000000..2542322 --- /dev/null +++ b/pkg/toolkit/presenter/jsonapi/stringify.go @@ -0,0 +1,43 @@ +package jsonapi + +import ( + "encoding/json" + + goooerrors "github.com/version-1/gooo/pkg/toolkit/errors" +) + +func Stringify(v any) string { + s, err := Escape(v) + if err != nil { + panic(err) + } + + if len(s) < 2 { + return s + } + + // Remove the quotes + if s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } else { + return s + } +} + +func Escape(i any) (string, error) { + b, err := json.Marshal(i) + if err != nil { + return "", goooerrors.Wrap(err) + } + + return string(b), nil +} + +func MustEscape(i any) string { + s, err := Escape(i) + if err != nil { + panic(err) + } + + return s +} diff --git a/pkg/presenter/view/view.go b/pkg/toolkit/presenter/view/view.go similarity index 100% rename from pkg/presenter/view/view.go rename to pkg/toolkit/presenter/view/view.go diff --git a/pkg/strings/strings.go b/pkg/toolkit/strings/strings.go similarity index 68% rename from pkg/strings/strings.go rename to pkg/toolkit/strings/strings.go index c10cf72..28f2779 100644 --- a/pkg/strings/strings.go +++ b/pkg/toolkit/strings/strings.go @@ -1,6 +1,7 @@ package strings import ( + "regexp" "strings" ) @@ -109,3 +110,40 @@ func ToKebabCase(s string) string { return string(s) }) } + +func ToPlural(word string) string { + w := strings.ToLower(word) + irregular := map[string]string{ + "man": "men", + "woman": "women", + "child": "children", + "tooth": "teeth", + "foot": "feet", + "mouse": "mice", + "person": "people", + } + + if plural, ok := irregular[w]; ok { + return plural + } + + // 末尾が "s", "x", "z", "ch", "sh" で終わる単語の場合 + if matched, _ := regexp.MatchString("(s|x|z|ch|sh)$", w); matched { + return word + "es" + } + + // 末尾が "f" または "fe" で終わる単語の場合 + if strings.HasSuffix(w, "fe") { + return word[:len(w)-2] + "ves" + } else if strings.HasSuffix(w, "f") { + return word[:len(w)-1] + "ves" + } + + // 子音 + yで終わる単語の場合 + if matched, _ := regexp.MatchString("[^aeiou]y$", w); matched { + return w[:len(w)-1] + "ies" + } + + // 通常の規則 (末尾に "s" を追加) + return w + "s" +} diff --git a/pkg/strings/strings_test.go b/pkg/toolkit/strings/strings_test.go similarity index 100% rename from pkg/strings/strings_test.go rename to pkg/toolkit/strings/strings_test.go diff --git a/pkg/testing/cleaner/adapter/pq.go b/pkg/toolkit/testing/cleaner/adapter/pq.go similarity index 98% rename from pkg/testing/cleaner/adapter/pq.go rename to pkg/toolkit/testing/cleaner/adapter/pq.go index 5816fa8..eade7c6 100644 --- a/pkg/testing/cleaner/adapter/pq.go +++ b/pkg/toolkit/testing/cleaner/adapter/pq.go @@ -5,7 +5,7 @@ import ( "fmt" "github.com/lib/pq" - "github.com/version-1/gooo/pkg/db" + "github.com/version-1/gooo/pkg/datasource/db" ) var excluded = pq.Array([]string{"schema_migrations"}) diff --git a/pkg/testing/cleaner/cleaner.go b/pkg/toolkit/testing/cleaner/cleaner.go similarity index 87% rename from pkg/testing/cleaner/cleaner.go rename to pkg/toolkit/testing/cleaner/cleaner.go index 131c9da..08ad2b2 100644 --- a/pkg/testing/cleaner/cleaner.go +++ b/pkg/toolkit/testing/cleaner/cleaner.go @@ -3,8 +3,8 @@ package cleaner import ( "context" - "github.com/version-1/gooo/pkg/db" - "github.com/version-1/gooo/pkg/testing/cleaner/adapter" + "github.com/version-1/gooo/pkg/datasource/db" + "github.com/version-1/gooo/pkg/toolkit/testing/cleaner/adapter" ) var _ CleanAdapter = (*adapter.Pq)(nil) diff --git a/pkg/toolkit/testing/table.go b/pkg/toolkit/testing/table.go new file mode 100644 index 0000000..2a1e78c --- /dev/null +++ b/pkg/toolkit/testing/table.go @@ -0,0 +1,30 @@ +package testing + +import "testing" + +type Record[A any, E any] struct { + Name string + Subject func(t *testing.T) (A, error) + Expect func(t *testing.T) (E, error) + Assert func(t *testing.T, r *Record[A, E]) bool +} + +type Table[A, E any] struct { + records []Record[A, E] +} + +func NewTable[A, E any](records []Record[A, E]) *Table[A, E] { + return &Table[A, E]{ + records: records, + } +} + +func (table *Table[A, E]) Run(test *testing.T) { + for _, record := range table.records { + test.Run(record.Name, func(t *testing.T) { + if !record.Assert(t, &record) { + t.Errorf("Test %s failed", record.Name) + } + }) + } +} diff --git a/pkg/toolkit/util/util.go b/pkg/toolkit/util/util.go new file mode 100644 index 0000000..cce6c43 --- /dev/null +++ b/pkg/toolkit/util/util.go @@ -0,0 +1,58 @@ +package util + +import ( + "os" + "path/filepath" + "strings" + + "github.com/google/uuid" + "github.com/version-1/gooo/pkg/toolkit/errors" +) + +func LookupGomodDirPath() (string, error) { + path, err := LookupFile("go.mod") + return filepath.Dir(path), err +} + +func LookupGomodPath() (string, error) { + return LookupFile("go.mod") +} + +func LookupFile(filename string) (string, error) { + path, err := os.Getwd() + if err != nil { + return "", errors.Wrap(err) + } + + parts := strings.Split(path, "/") + for i := len(parts) - 1; i >= 0; i-- { + target := filepath.Join(strings.Join(parts[:i], "/"), filename) + if _, err := os.Stat(target); err == nil { + return target, nil + } + } + + return "", errors.Errorf("%s not found", filename) +} + +func Basename(path string) string { + l := filepath.Ext(path) + return filepath.Base(path)[:len(filepath.Base(path))-len(l)] +} + +func IsZero(v any) (bool, error) { + switch vv := v.(type) { + case string: + return vv == "", nil + case int: + return vv == 0, nil + case bool: + return vv == false, nil + case rune: + return vv == 0, nil + case uuid.UUID: + return vv == uuid.Nil, nil + default: + return false, errors.Errorf("unsupported type: %T", v) + } +} diff --git a/pkg/toolkit/util/util_test.go b/pkg/toolkit/util/util_test.go new file mode 100644 index 0000000..64ddd2f --- /dev/null +++ b/pkg/toolkit/util/util_test.go @@ -0,0 +1,17 @@ +package util + +import ( + "strings" + "testing" +) + +func TestUtilLookupGomodDirPath(t *testing.T) { + path, err := LookupGomodDirPath() + if err != nil { + t.Errorf("Error: %+v", err) + } + + if !strings.HasSuffix(path, "gooo") { + t.Errorf("Expected path to end with 'gooo', got %s", path) + } +}