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(` + + + + Swagger + + + + + +
+ + + + + `, hostURL)) + +} + +//go:embed *.yml +var swaggerConf embed.FS + +func SwaggerYAML() ([]byte, error) { + f, err := fs.ReadFile(swaggerConf, "swagger.yml") + if err != nil { + return f, err + } + + return f, nil +} diff --git a/examples/bare/internal/swagger/swagger.yml b/examples/bare/internal/swagger/swagger.yml new file mode 100644 index 0000000..baf0846 --- /dev/null +++ b/examples/bare/internal/swagger/swagger.yml @@ -0,0 +1,229 @@ +openapi: 3.0.0 +info: + title: Sample API + description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML. + version: 0.1.9 + +servers: + - url: http://localhost:8080/api/v1 + description: Optional server description, e.g. Main (production) server + +tags: + - name: User + description: Operations related to users + - name: Post + description: Operations related to posts + +paths: + /users: + get: + tags: + - User + summary: Returns a list of users. + description: Optional extended description in CommonMark or HTML. + responses: + "200": # status code + description: A JSON array of user names + content: + application/json: + schema: + type: array + fuga: "hoge" + items: + $ref: '#/components/schemas/User' + post: + tags: + - User + summary: Create a User. + description: Optional extended description in CommonMark or HTML. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MutateUser' + responses: + "201": # status code + description: A JSON array of user names + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /users/{id}: + get: + tags: + - User + summary: Returns a user. + description: Optional extended description in CommonMark or HTML. + responses: + "200": + description: A JSON array of user names + content: + application/json: + schema: + $ref: '#/components/schemas/User' + patch: + tags: + - User + summary: Returns a user. + description: Optional extended description in CommonMark or HTML. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MutateUser' + responses: + "200": + description: A JSON array of user names + content: + application/json: + schema: + $ref: '#/components/schemas/User' + delete: + tags: + - User + summary: Delete a user. + description: Optional extended description in CommonMark or HTML. + responses: + "200": + description: A JSON array of user names + content: + application/json: + schema: + $ref: '#/components/schemas/User' + /posts: + get: + tags: + - Post + summary: Returns a list of posts. + description: Optional extended description in CommonMark or HTML. + responses: + "200": + description: A JSON array of posts + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Post' + post: + tags: + - Post + summary: Create a Post. + description: Optional extended description in CommonMark or HTML. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MutatePost' + responses: + "201": + description: A JSON array of posts + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Post' + /posts/{id}: + get: + tags: + - Post + summary: Returns a post. + description: Optional extended description in CommonMark or HTML. + responses: + "200": + description: A JSON array of posts + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + patch: + tags: + - Post + summary: Update a post. + description: Optional extended description in CommonMark or HTML. + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MutatePost' + responses: + "200": + description: A JSON array of posts + content: + application/json: + schema: + $ref: '#/components/schemas/Post' + delete: + tags: + - Post + summary: Delete a post. + description: Optional extended description in CommonMark or HTML. + responses: + "200": + description: A JSON array of posts + content: + application/json: + schema: + $ref: '#/components/schemas/Post' +components: + schemas: + Error: + type: object + properties: + code: + type: integer + format: int32 + message: + type: string + User: + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + sample: "John Doe" + obj: + type: object + properties: + hoge: + type: string + sample: "hoge" + fuga: + type: string + sample: "fuga" + MutateUser: + type: object + properties: + username: + type: string + sample: "John Doe" + Post: + type: object + properties: + id: + type: integer + format: int64 + userId: + type: integer + format: int64 + title: + type: string + sample: "Sample Post Title" + content: + type: string + sample: "This is a sample post content." + MutatePost: + type: object + properties: + title: + type: string + sample: "Sample Post Title" + content: + type: string + sample: "This is a sample post content." + diff --git a/examples/core/cmd/app.go b/examples/core/cmd/app.go new file mode 100644 index 0000000..32a8bc8 --- /dev/null +++ b/examples/core/cmd/app.go @@ -0,0 +1,22 @@ +package main + +import ( + "fmt" + + schema "github.com/version-1/gooo/pkg/core/schema" + "github.com/version-1/gooo/pkg/core/schema/openapi/v3_0_0" +) + +func main() { + s, err := v3_0_0.New("./examples/bare/internal/swagger/swagger.yml") + if err != nil { + panic(err) + } + + g := schema.NewGenerator(s, "./examples/core/generated", "github.com/version-1/gooo/examples/core/generated") + + if err := g.Generate(); err != nil { + fmt.Printf("Error: %+v\n", err) + panic(err) + } +} diff --git a/examples/core/generated/internal/schema/schema.go b/examples/core/generated/internal/schema/schema.go new file mode 100644 index 0000000..e62aa4d --- /dev/null +++ b/examples/core/generated/internal/schema/schema.go @@ -0,0 +1,33 @@ +package schema + +// This is a generated file. DO NOT EDIT manually. + +type Error struct { + Code int + Message string +} + +type User struct { + ID int + Username string + Obj struct { + Hoge string + Fuga string + } +} + +type MutateUser struct { + Username string +} + +type Post struct { + ID int + userID int + Title string + Content string +} + +type MutatePost struct { + Title string + Content string +} diff --git a/examples/core/generated/main.go b/examples/core/generated/main.go new file mode 100644 index 0000000..1936242 --- /dev/null +++ b/examples/core/generated/main.go @@ -0,0 +1,75 @@ +package main + +// This is a generated file. DO NOT EDIT manually. +import ( + "context" + "log" + "net/http" + + "github.com/version-1/gooo/examples/core/generated/internal/schema" + "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" +) + +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{ + route.JSON[schema.MutateUser, schema.User]().Post("/users", func(res *response.Response[schema.User], req *request.Request[schema.MutateUser]) { + // do something + }), + route.JSON[request.Void, schema.User]().Get("/users", func(res *response.Response[schema.User], req *request.Request[request.Void]) { + // do something + }), + route.JSON[request.Void, schema.User]().Get("/users/{id}", func(res *response.Response[schema.User], req *request.Request[request.Void]) { + // do something + }), + route.JSON[schema.MutateUser, schema.User]().Patch("/users/{id}", func(res *response.Response[schema.User], req *request.Request[schema.MutateUser]) { + // do something + }), + route.JSON[request.Void, schema.User]().Delete("/users/{id}", func(res *response.Response[schema.User], req *request.Request[request.Void]) { + // do something + }), + route.JSON[request.Void, schema.Post]().Get("/posts", func(res *response.Response[schema.Post], req *request.Request[request.Void]) { + // do something + }), + route.JSON[schema.MutatePost, schema.Post]().Post("/posts", func(res *response.Response[schema.Post], req *request.Request[schema.MutatePost]) { + // do something + }), + route.JSON[schema.MutatePost, schema.Post]().Patch("/posts/{id}", func(res *response.Response[schema.Post], req *request.Request[schema.MutatePost]) { + // do something + }), + route.JSON[request.Void, schema.Post]().Delete("/posts/{id}", func(res *response.Response[schema.Post], req *request.Request[request.Void]) { + // do something + }), + route.JSON[request.Void, schema.Post]().Get("/posts/{id}", func(res *response.Response[schema.Post], req *request.Request[request.Void]) { + // do something + }), + }, + } + app.WithDefaultMiddlewares(srv, routes.Children()...) +} diff --git a/examples/starter/cmd/api/main.go b/examples/starter/cmd/api/main.go deleted file mode 100644 index f370574..0000000 --- a/examples/starter/cmd/api/main.go +++ /dev/null @@ -1,181 +0,0 @@ -package main - -import ( - "context" - "fmt" - "net/http" - "os" - "text/tabwriter" - "time" - - "github.com/version-1/gooo/pkg/app" - "github.com/version-1/gooo/pkg/config" - "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/logger" -) - -type Dummy struct { - String string `json:"string"` - Number int `json:"number"` - Flag bool `json:"flag"` - Time time.Time `json:"time"` -} - -type DummyError struct { -} - -func (e DummyError) Error() string { - return "dummy error" -} - -func (e DummyError) Code() string { - return "dummy_error" -} - -func (e DummyError) Title() string { - return "Dummy Error" -} - -func main() { - ping := controller.Handler{ - Path: "/ping", - Method: http.MethodGet, - Handler: func(w *response.Response, r *request.Request) { - w.JSON(map[string]string{"message": "pong"}) - }, - } - - testing := controller.GroupHandler{ - Path: "/testing", - Handlers: []controller.Handler{ - { - Path: "/render", - Method: http.MethodGet, - Handler: func(w *response.Response, r *request.Request) { - data := Dummy{ - String: "Hello, World!", - Number: 42, - Flag: true, - Time: time.Now(), - } - if err := w.Render(data); err != nil { - if err := w.InternalServerErrorWith(err); err != nil { - fmt.Printf("stacktrace ==========%+v\n", err) - } - } - }, - }, - { - Path: "/render_error", - Method: http.MethodGet, - Handler: func(w *response.Response, r *request.Request) { - if err := w.RenderError(fmt.Errorf("error")); err != nil { - w.InternalServerErrorWith(err) - } - }, - }, - { - Path: "/interal_server_error", - Method: http.MethodGet, - Handler: func(w *response.Response, r *request.Request) { - if err := w.InternalServerErrorWith(DummyError{}); err != nil { - w.InternalServerErrorWith(err) - } - }, - }, - { - Path: "/bad_request", - Method: http.MethodGet, - Handler: func(w *response.Response, r *request.Request) { - if err := w.InternalServerErrorWith(DummyError{}); err != nil { - w.InternalServerErrorWith(err) - } - }, - }, - { - Path: "/unauthorized", - Method: http.MethodGet, - Handler: func(w *response.Response, r *request.Request) { - if err := w.UnauthorizedWith(DummyError{}); err != nil { - w.InternalServerErrorWith(err) - } - }, - }, - { - Path: "/forbidden", - Method: http.MethodGet, - Handler: func(w *response.Response, r *request.Request) { - if err := w.ForbiddenWith(DummyError{}); err != nil { - w.InternalServerErrorWith(err) - } - }, - }, - { - Path: "/not_found", - Method: http.MethodGet, - Handler: func(w *response.Response, r *request.Request) { - if err := w.NotFoundWith(DummyError{}); err != nil { - w.InternalServerErrorWith(err) - } - }, - }, - }, - } - - users := controller.GroupHandler{ - Path: "/users", - Handlers: []controller.Handler{ - { - Path: "/", - Method: http.MethodGet, - }, - { - Path: "/", - Method: http.MethodPost, - }, - { - Path: "/:id", - Method: http.MethodPatch, - }, - { - Path: "/:id", - Method: http.MethodGet, - }, - { - Path: "/:id", - Method: http.MethodDelete, - }, - }, - }.List() - - apiRoot := controller.GroupHandler{ - Path: "/api/v1", - } - apiRoot.Add(users...) - apiRoot.Add(ping) - apiRoot.Add(testing.List()...) - - cfg := &config.App{ - Logger: logger.DefaultLogger, - DefaultResponseRenderer: config.JSONAPIRenderer, - } - - s := app.Server{ - Addr: ":8080", - Config: cfg, - } - s.RegisterHandlers(apiRoot.List()...) - app.WithDefaultMiddlewares(&s) - - w := tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0) - fmt.Fprint(w, logger.DefaultLogger.SInfof("Path\t|\tMethod")) - s.WalkThrough(func(h controller.Handler) { - fmt.Fprint(w, logger.DefaultLogger.SInfof("%s\t|\t%s\t", h.Path, h.Method)) - }) - fmt.Fprint(w, logger.DefaultLogger.SInfof("")) - w.Flush() - - s.Run(context.Background()) -} diff --git a/examples/starter/cmd/migration/main.go b/examples/starter/cmd/migration/main.go deleted file mode 100644 index 208f1f0..0000000 --- a/examples/starter/cmd/migration/main.go +++ /dev/null @@ -1,46 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - - "github.com/jmoiron/sqlx" - "github.com/version-1/gooo/pkg/command/migration" - "github.com/version-1/gooo/pkg/command/migration/runner" -) - -func main() { - db, err := sqlx.Connect("postgres", os.Getenv("DATABASE_URL")) - if err != nil { - panic(err) - } - - ctx := context.Background() - m, err := runner.NewYaml(db, os.Getenv("MIGRATION_PATH")) - if err != nil { - panic(err) - } - - c, err := migration.NewWith(db, m, nil) - if err != nil { - panic(err) - } - - if len(os.Args) == 1 { - fmt.Println("command is required. [up|down|create|drop|generate]") - os.Exit(1) - return - } - - cmd := os.Args[1] - - args := []string{} - if len(os.Args) >= 3 { - args = os.Args[2:] - } - - if err := c.Exec(ctx, cmd, args...); err != nil { - panic(err) - } -} diff --git a/examples/starter/cmd/schema/main.go b/examples/starter/cmd/schema/main.go deleted file mode 100644 index cf8a991..0000000 --- a/examples/starter/cmd/schema/main.go +++ /dev/null @@ -1,16 +0,0 @@ -package main - -import ( - "os" - - "github.com/version-1/gooo/examples/starter/schema" -) - -func main() { - args := os.Args[1:] - - dirpath := args[0] - if err := schema.Run(dirpath); err != nil { - panic(err) - } -} diff --git a/examples/starter/cmd/seed/main.go b/examples/starter/cmd/seed/main.go deleted file mode 100644 index 2bbac54..0000000 --- a/examples/starter/cmd/seed/main.go +++ /dev/null @@ -1,15 +0,0 @@ -package main - -import ( - "os" - - "github.com/version-1/gooo/examples/starter/db/seeders" - "github.com/version-1/gooo/pkg/command/seeder" -) - -func main() { - seed := seeders.NewDevelopmentSeed(os.Getenv("DATABASE_URL")) - - ex := seeder.New(seed) - ex.Run() -} diff --git a/examples/starter/db/migrations/00000000000000_initial.yaml b/examples/starter/db/migrations/00000000000000_initial.yaml deleted file mode 100644 index f139cb0..0000000 --- a/examples/starter/db/migrations/00000000000000_initial.yaml +++ /dev/null @@ -1,64 +0,0 @@ -tables: - - name: migration_test_users - columns: - - name: id - type: int - primary_key: true - - name: name - type: varchar - - name: email - type: varchar - - name: created_at - type: timestamp - default: "CURRENT_TIMESTAMP" - - name: updated_at - type: timestamp - default: "CURRENT_TIMESTAMP" - indexes: - - name: unique_name_email - columns: [name, email] - unique: true - - name: migration_test_posts - columns: - - name: id - type: int - primary_key: true - - name: user_id - type: int - - name: title - type: varchar - - name: body - type: text - - name: created_at - type: timestamp - default: "CURRENT_TIMESTAMP" - - name: updated_at - type: timestamp - default: "CURRENT_TIMESTAMP" - indexes: - - columns: [user_id] - - name: user_ref_idx - columns: [user_id] - foreign_key: - table: migration_test_users - column: id - - name: migration_test_comments - columns: - - name: id - type: int - primary_key: true - - name: post_id - type: int - - name: user_id - type: int - - name: body - type: text - - name: created_at - type: timestamp - default: "CURRENT_TIMESTAMP" - - name: updated_at - type: timestamp - default: "CURRENT_TIMESTAMP" - indexes: - - columns: [post_id] - - columns: [user_id] diff --git a/examples/starter/db/migrations/20240909000000_modify_columns-1.down.yaml b/examples/starter/db/migrations/20240909000000_modify_columns-1.down.yaml deleted file mode 100644 index 4952ec2..0000000 --- a/examples/starter/db/migrations/20240909000000_modify_columns-1.down.yaml +++ /dev/null @@ -1 +0,0 @@ -query: "SELECT 1;" diff --git a/examples/starter/db/migrations/20240909000000_modify_columns-1.up.yaml b/examples/starter/db/migrations/20240909000000_modify_columns-1.up.yaml deleted file mode 100644 index 4952ec2..0000000 --- a/examples/starter/db/migrations/20240909000000_modify_columns-1.up.yaml +++ /dev/null @@ -1 +0,0 @@ -query: "SELECT 1;" diff --git a/examples/starter/db/migrations/20240910000000_modify_columns-2.down.yaml b/examples/starter/db/migrations/20240910000000_modify_columns-2.down.yaml deleted file mode 100644 index 4952ec2..0000000 --- a/examples/starter/db/migrations/20240910000000_modify_columns-2.down.yaml +++ /dev/null @@ -1 +0,0 @@ -query: "SELECT 1;" diff --git a/examples/starter/db/migrations/20240910000000_modify_columns-2.up.yaml b/examples/starter/db/migrations/20240910000000_modify_columns-2.up.yaml deleted file mode 100644 index 4952ec2..0000000 --- a/examples/starter/db/migrations/20240910000000_modify_columns-2.up.yaml +++ /dev/null @@ -1 +0,0 @@ -query: "SELECT 1;" diff --git a/examples/starter/db/seeders/development.go b/examples/starter/db/seeders/development.go deleted file mode 100644 index 7652974..0000000 --- a/examples/starter/db/seeders/development.go +++ /dev/null @@ -1,42 +0,0 @@ -package seeders - -import ( - "github.com/jmoiron/sqlx" - "github.com/version-1/gooo/pkg/command/seeder" - "github.com/version-1/gooo/pkg/logger" -) - -type DevelopmentSeed struct { - connstr string -} - -func NewDevelopmentSeed(connstr string) DevelopmentSeed { - return DevelopmentSeed{ - connstr: connstr, - } -} - -func (s DevelopmentSeed) Connstr() string { - return s.connstr -} - -func (s DevelopmentSeed) Seeders() []seeder.Seeder { - return []seeder.Seeder{ - Seed_0001_User{}, - } -} - -func (S DevelopmentSeed) Logger() seeder.Logger { - return logger.DefaultLogger -} - -type Seed_0001_User struct{} - -func (s Seed_0001_User) Exec(tx *sqlx.Tx) error { - query := "INSERT INTO seeder_users (name, email) VALUES ('John Doe', 'john@example.com')" - if _, err := tx.Exec(query); err != nil { - return err - } - - return nil -} diff --git a/examples/starter/models/fixtures/test_resource_serialize.json b/examples/starter/models/fixtures/test_resource_serialize.json deleted file mode 100644 index 5d0087b..0000000 --- a/examples/starter/models/fixtures/test_resource_serialize.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "data": { - "id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "type": "user", - "attributes": { - "id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "username": "test", - "bio": null, - "email": "test@example.com", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": { - "posts": { - "data": [ - { - "id": "ccf7a495-ec22-4358-bccd-d77bec8ee037", - "type": "post" - }, - { - "id": "f7b1b3b4-3b3b-4b3b-8b3b-3b3b3b3b3b3b", - "type": "post" - } - ] - } - } - }, - "errors": [], - "included": [ - { - "id": "ccf7a495-ec22-4358-bccd-d77bec8ee037", - "type": "post", - "attributes": { - "id": "ccf7a495-ec22-4358-bccd-d77bec8ee037", - "user_id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "title": "title1", - "body": "body1", - "status": "published", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": { - "user": { - "data": { - "id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "type": "user" - } - } - } - }, - { - "id": "f7b1b3b4-3b3b-4b3b-8b3b-3b3b3b3b3b3b", - "type": "post", - "attributes": { - "id": "f7b1b3b4-3b3b-4b3b-8b3b-3b3b3b3b3b3b", - "user_id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "title": "title2", - "body": "body2", - "status": "published", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": { - "user": { - "data": { - "id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "type": "user" - } - } - } - }, - { - "id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "type": "user", - "attributes": { - "id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "username": "test", - "bio": null, - "email": "test@example.com", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": {} - } - ] -} diff --git a/examples/starter/models/fixtures/test_resources_serialize.json b/examples/starter/models/fixtures/test_resources_serialize.json deleted file mode 100644 index 6c8394d..0000000 --- a/examples/starter/models/fixtures/test_resources_serialize.json +++ /dev/null @@ -1,181 +0,0 @@ -{ - "data": [ - { - "id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "type": "user", - "attributes": { - "id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "username": "test0", - "bio": null, - "email": "test0@example.com", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": { - "posts": { - "data": [ - { - "id": "15fa357d-089d-4816-9924-65a8e2a91eba", - "type": "post" - } - ] - } - } - }, - { - "id": "ccf7a495-ec22-4358-bccd-d77bec8ee037", - "type": "user", - "attributes": { - "id": "ccf7a495-ec22-4358-bccd-d77bec8ee037", - "username": "test1", - "bio": null, - "email": "test1@example.com", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": { - "posts": { - "data": [ - { - "id": "e1222719-b9b6-4191-99c6-9b159884f534", - "type": "post" - } - ] - } - } - }, - { - "id": "f7b1b3b4-3b3b-4b3b-8b3b-3b3b3b3b3b3b", - "type": "user", - "attributes": { - "id": "f7b1b3b4-3b3b-4b3b-8b3b-3b3b3b3b3b3b", - "username": "test2", - "bio": null, - "email": "test2@example.com", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": { - "posts": { - "data": [ - { - "id": "17b89f20-d638-4b6a-b732-1b8f08a914d1", - "type": "post" - } - ] - } - } - } - ], - "meta": { - "has_next": true, - "has_prev": true, - "page": 1, - "total": 3 - }, - "errors": [], - "included": [ - { - "id": "15fa357d-089d-4816-9924-65a8e2a91eba", - "type": "post", - "attributes": { - "id": "15fa357d-089d-4816-9924-65a8e2a91eba", - "user_id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "title": "title0", - "body": "body0", - "status": "published", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": { - "user": { - "data": { - "id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "type": "user" - } - } - } - }, - { - "id": "17b89f20-d638-4b6a-b732-1b8f08a914d1", - "type": "post", - "attributes": { - "id": "17b89f20-d638-4b6a-b732-1b8f08a914d1", - "user_id": "f7b1b3b4-3b3b-4b3b-8b3b-3b3b3b3b3b3b", - "title": "title2", - "body": "body2", - "status": "published", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": { - "user": { - "data": { - "id": "f7b1b3b4-3b3b-4b3b-8b3b-3b3b3b3b3b3b", - "type": "user" - } - } - } - }, - { - "id": "e1222719-b9b6-4191-99c6-9b159884f534", - "type": "post", - "attributes": { - "id": "e1222719-b9b6-4191-99c6-9b159884f534", - "user_id": "ccf7a495-ec22-4358-bccd-d77bec8ee037", - "title": "title1", - "body": "body1", - "status": "published", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": { - "user": { - "data": { - "id": "ccf7a495-ec22-4358-bccd-d77bec8ee037", - "type": "user" - } - } - } - }, - { - "id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "type": "user", - "attributes": { - "id": "4018be75-e855-489d-a151-ddb8fc3fd2dc", - "username": "test0", - "bio": null, - "email": "test0@example.com", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": {} - }, - { - "id": "ccf7a495-ec22-4358-bccd-d77bec8ee037", - "type": "user", - "attributes": { - "id": "ccf7a495-ec22-4358-bccd-d77bec8ee037", - "username": "test1", - "bio": null, - "email": "test1@example.com", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": {} - }, - { - "id": "f7b1b3b4-3b3b-4b3b-8b3b-3b3b3b3b3b3b", - "type": "user", - "attributes": { - "id": "f7b1b3b4-3b3b-4b3b-8b3b-3b3b3b3b3b3b", - "username": "test2", - "bio": null, - "email": "test2@example.com", - "created_at": "2024-08-07T01:58:13 +0000", - "updated_at": "2024-08-07T01:58:13 +0000" - }, - "relationships": {} - } - ] -} diff --git a/examples/starter/models/jsonapi_test.go b/examples/starter/models/jsonapi_test.go deleted file mode 100644 index 7df63fb..0000000 --- a/examples/starter/models/jsonapi_test.go +++ /dev/null @@ -1,219 +0,0 @@ -package models - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "strconv" - "strings" - "testing" - "time" - - "github.com/google/uuid" - "github.com/version-1/gooo/pkg/presenter/jsonapi" -) - -type Meta struct { - Total int - Page int - HasNext bool - HasPrev bool -} - -func (m Meta) JSONAPISerialize() (string, error) { - data := map[string]any{ - "total": m.Total, - "page": m.Page, - "has_next": m.HasNext, - "has_prev": m.HasPrev, - } - - b, err := json.Marshal(data) - if err != nil { - return "", err - } - - return string(b), nil -} - -func TestResourcesSerialize(t *testing.T) { - now, err := time.Parse(time.RFC3339, "2024-08-07T01:58:13+00:00") - if err != nil { - t.Fatal(err) - } - - uid := []uuid.UUID{ - uuid.MustParse("4018be75-e855-489d-a151-ddb8fc3fd2dc"), - uuid.MustParse("ccf7a495-ec22-4358-bccd-d77bec8ee037"), - uuid.MustParse("f7b1b3b4-3b3b-4b3b-8b3b-3b3b3b3b3b3b"), - } - - postID := []uuid.UUID{ - uuid.MustParse("15fa357d-089d-4816-9924-65a8e2a91eba"), - uuid.MustParse("e1222719-b9b6-4191-99c6-9b159884f534"), - uuid.MustParse("17b89f20-d638-4b6a-b732-1b8f08a914d1"), - } - - users := []User{} - for i, id := range uid { - u := NewUser() - uu := User{ - ID: id, - Username: "test" + strconv.Itoa(i), - Email: fmt.Sprintf("test%d@example.com", i), - CreatedAt: now, - UpdatedAt: now, - } - u.Assign(User{ - ID: id, - Username: "test" + strconv.Itoa(i), - Email: fmt.Sprintf("test%d@example.com", i), - CreatedAt: now, - UpdatedAt: now, - Posts: []Post{ - { - ID: postID[i], - UserID: id, - User: uu, - Title: "title" + strconv.Itoa(i), - Body: "body" + strconv.Itoa(i), - Status: "published", - CreatedAt: now, - UpdatedAt: now, - }, - }, - }) - - users = append(users, *u) - } - root := jsonapi.NewManyFrom( - users, - Meta{ - Total: 3, - Page: 1, - HasNext: true, - HasPrev: true, - }, - ) - - s, err := root.Serialize() - if err != nil { - t.Fatal(err) - } - - expected, err := os.ReadFile("./fixtures/test_resources_serialize.json") - if err != nil { - t.Fatal(err) - } - - expectedStr := strings.ReplaceAll(string(expected), " ", "\t") - // trailing newline - expectedStr = string(expectedStr[0 : len(expectedStr)-1]) - - if err := diff(expectedStr, s); err != nil { - fmt.Printf("expect %s\n\n got %s", expectedStr, s) - t.Fatal(err) - } -} - -func TestResourceSerialize(t *testing.T) { - now, err := time.Parse(time.RFC3339, "2024-08-07T01:58:13+00:00") - if err != nil { - t.Fatal(err) - } - - uid := uuid.MustParse("4018be75-e855-489d-a151-ddb8fc3fd2dc") - p1 := uuid.MustParse("ccf7a495-ec22-4358-bccd-d77bec8ee037") - p2 := uuid.MustParse("f7b1b3b4-3b3b-4b3b-8b3b-3b3b3b3b3b3b") - uu := NewUserWith(User{ - ID: uid, - Username: "test", - Email: "test@example.com", - CreatedAt: now, - UpdatedAt: now, - }) - u := NewUserWith(User{ - ID: uid, - Username: "test", - Email: "test@example.com", - CreatedAt: now, - UpdatedAt: now, - Posts: []Post{ - { - ID: p1, - UserID: uid, - User: *uu, - Title: "title1", - Body: "body1", - Status: "published", - CreatedAt: now, - UpdatedAt: now, - }, - { - ID: p2, - UserID: uid, - User: *uu, - Title: "title2", - Body: "body2", - Status: "published", - CreatedAt: now, - UpdatedAt: now, - }, - }, - }) - - resource, includes := u.ToJSONAPIResource() - - root := jsonapi.New(resource, includes, nil) - - s, err := root.Serialize() - if err != nil { - t.Fatal(err) - } - - expected, err := os.ReadFile("./fixtures/test_resource_serialize.json") - if err != nil { - t.Fatal(err) - } - - expectedStr := strings.ReplaceAll(string(expected), " ", "\t") - // trailing newline - expectedStr = string(expectedStr[0 : len(expectedStr)-1]) - - if err := diff(expectedStr, s); err != nil { - fmt.Printf("expect %s\n\n got %s", expectedStr, s) - t.Fatal(err) - } -} - -func diff(expected, got string) error { - line := 1 - for i := 0; i < len(expected); i++ { - if i >= len(got) { - return errors.New(fmt.Sprintf("got diff at %d line %d. expected(%d), but got(%d)", i, line, len(expected), len(got))) - } - - if expected[i] != got[i] { - expectedLines := strings.Split(expected, "\n") - gotLines := strings.Split(got, "\n") - msg := fmt.Sprintf("got diff at %d line %d. expected \"%s\", but got \"%s\"", i, line, string(expected[i]), string(got[i])) - if line > 1 { - msg += fmt.Sprintf(" %s\n", expectedLines[line-1-1]) - } - msg += fmt.Sprintf("- %s\n", expectedLines[line-1]) - if line < len(expectedLines) { - msg += fmt.Sprintf("- %s\n", expectedLines[line]) - } - msg += "\n\n\n" - msg += fmt.Sprintf("+ %s\n", gotLines[line-1]) - return errors.New(msg) - } - - if expected[i] == '\n' { - line++ - } - } - - return nil -} diff --git a/examples/starter/models/orm_test.go b/examples/starter/models/orm_test.go deleted file mode 100644 index 372bbb4..0000000 --- a/examples/starter/models/orm_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package models - -import ( - "context" - "log" - "os" - "testing" - - "github.com/google/uuid" - "github.com/jmoiron/sqlx" - _ "github.com/lib/pq" - "github.com/version-1/gooo/pkg/datasource/logging" - "github.com/version-1/gooo/pkg/datasource/orm" -) - -func TestValidaiton(t *testing.T) { - t.Skip("TODO:") -} - -func TestCRUD(t *testing.T) { - db, err := sqlx.Connect("postgres", os.Getenv("DATABASE_URL")) - if err != nil { - log.Fatalln(err) - } - - o := orm.New(db, &logging.MockLogger{}, orm.Options{QueryLog: true}) - - u := NewUserWith(User{ - ID: uuid.New(), - Username: "test", - Email: "hoge@example.com", - }) - - ctx := context.Background() - if err := u.Save(ctx, o); err != nil { - t.Fatal(err) - } - - u2 := NewUserWith(User{ - ID: u.ID, - }) - - if err := u2.Find(ctx, o); err != nil { - t.Fatal(err) - } - - if u2.ID != u.ID { - t.Fatalf("id is expected to %s, but got %s", u.ID, u2.ID) - } - - if u2.Username != u.Username { - t.Fatalf("username is expected to %s, but got %s", u.Username, u2.Username) - } - - if u2.Email != u.Email { - t.Fatalf("email is expected to %s, but got %s", u.Email, u2.Email) - } - - if u2.CreatedAt != u.CreatedAt { - t.Fatalf("createdAt is expected to %s, but got %s", u.CreatedAt, u2.CreatedAt) - } - - if u2.UpdatedAt != u.UpdatedAt { - t.Fatalf("updatedAt is expected to %s, but got %s", u.UpdatedAt, u2.UpdatedAt) - } - - if err := u2.Destroy(ctx, o); err != nil { - t.Fatal(err) - } -} diff --git a/examples/starter/models/post.go b/examples/starter/models/post.go deleted file mode 100644 index a5ab094..0000000 --- a/examples/starter/models/post.go +++ /dev/null @@ -1,163 +0,0 @@ -package models - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - goooerrors "github.com/version-1/gooo/pkg/datasource/orm/errors" - "github.com/version-1/gooo/pkg/presenter/jsonapi" - "github.com/version-1/gooo/pkg/schema" -) - -type Post struct { - schema.Schema - // db related fields - ID uuid.UUID - UserID uuid.UUID - Title string - Body string - Status string - CreatedAt time.Time - UpdatedAt time.Time - - // non-db related fields - User User -} - -func (obj Post) Columns() []string { - return []string{"id", "user_id", "title", "body", "status", "created_at", "updated_at"} -} - -func (obj *Post) Scan(rows scanner) error { - if err := rows.Scan(&obj.ID, &obj.UserID, &obj.Title, &obj.Body, &obj.Status, &obj.CreatedAt, &obj.UpdatedAt); err != nil { - return err - } - - return nil -} - -func (obj *Post) Destroy(ctx context.Context, qr queryer) error { - if obj.ID == uuid.Nil { - return ErrPrimaryKeyMissing - } - - query := "DELETE FROM posts WHERE id = $1" - if _, err := qr.ExecContext(ctx, query, obj.ID); err != nil { - return err - } - - return nil -} - -func (obj *Post) Find(ctx context.Context, qr queryer) error { - if obj.ID == uuid.Nil { - return ErrPrimaryKeyMissing - } - - query := "SELECT id, user_id, title, body, status, created_at, updated_at FROM posts 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 -} - -func (obj *Post) Save(ctx context.Context, qr queryer) error { - if err := obj.validate(); err != nil { - return err - } - query := ` - INSERT INTO posts (user_id, title, body, status) VALUES ($1, $2, $3) - ON CONFLICT(id) DO UPDATE SET user_id = $1, title = $2, body = $3, status = $4, updated_at = NOW() - RETURNING id, user_id, title, body, status, created_at, updated_at - ` - - row := qr.QueryRowContext(ctx, query, obj.UserID, obj.Title, obj.Body, obj.Status) - if err := obj.Scan(row); err != nil { - return err - } - - return nil -} - -func (obj *Post) Assign(v Post) { - obj.ID = v.ID - obj.UserID = v.UserID - obj.Title = v.Title - obj.Body = v.Body - obj.Status = v.Status - obj.CreatedAt = v.CreatedAt - obj.UpdatedAt = v.UpdatedAt - obj.User = v.User -} - -func (obj Post) validate() goooerrors.ValidationError { - validator := obj.Schema.Fields[1].Options.Validators[0] - if err := validator.Validate("UserID")(obj.UserID); err != nil { - return err - } - - validator = obj.Schema.Fields[2].Options.Validators[0] - if err := validator.Validate("Title")(obj.Title); err != nil { - return err - } - - validator = obj.Schema.Fields[3].Options.Validators[0] - if err := validator.Validate("Body")(obj.Body); err != nil { - return err - } - - return nil -} - -func (obj Post) JSONAPISerialize() (string, error) { - lines := []string{ - fmt.Sprintf("\"id\": %s", jsonapi.Stringify(obj.ID)), - fmt.Sprintf("\"user_id\": %s", jsonapi.Stringify(obj.UserID)), - fmt.Sprintf("\"title\": %s", jsonapi.Stringify(obj.Title)), - fmt.Sprintf("\"body\": %s", jsonapi.Stringify(obj.Body)), - fmt.Sprintf("\"status\": %s", jsonapi.Stringify(obj.Status)), - fmt.Sprintf("\"created_at\": %s", jsonapi.Stringify(obj.CreatedAt)), - fmt.Sprintf("\"updated_at\": %s", jsonapi.Stringify(obj.UpdatedAt)), - } - return fmt.Sprintf("{\n%s\n}", strings.Join(lines, ", \n")), nil -} - -func (obj Post) ToJSONAPIResource() (jsonapi.Resource, jsonapi.Resources) { - includes := &jsonapi.Resources{} - r := jsonapi.Resource{ - ID: jsonapi.Stringify(obj.ID), - Type: "post", - Attributes: obj, - Relationships: jsonapi.Relationships{}, - } - - ele := obj.User - if ele.ID == (User{}).ID { - return r, *includes - } - relationship := jsonapi.Relationship{ - Data: jsonapi.ResourceIdentifier{ - ID: jsonapi.Stringify(ele.ID), - Type: "user", - }, - } - - resource, childIncludes := ele.ToJSONAPIResource() - includes.Append(resource) - includes.Append(childIncludes.Data...) - - r.Relationships["user"] = relationship - return r, *includes -} diff --git a/examples/starter/models/shared.go b/examples/starter/models/shared.go deleted file mode 100644 index 4048118..0000000 --- a/examples/starter/models/shared.go +++ /dev/null @@ -1,64 +0,0 @@ -package models - -// this file is generated by gooo ORM. DON'T EDIT this file -import ( - "context" - "database/sql" - - "github.com/version-1/gooo/examples/starter/schema" -) - -type scanner interface { - Scan(dest ...any) error -} -type queryer interface { - 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) -} - -type NotFoundError struct{} - -func (e NotFoundError) Error() string { - return "record not found" -} - -var ErrNotFound = NotFoundError{} - -type PrimaryKeyMissingError struct{} - -func (e PrimaryKeyMissingError) Error() string { - return "primary key is required" -} - -var ErrPrimaryKeyMissing = PrimaryKeyMissingError{} - -func NewUser() *User { - return &User{ - Schema: schema.UserSchema, - } -} - -func NewUserWith(obj User) *User { - m := &User{ - Schema: schema.UserSchema, - } - m.Assign(obj) - - return m -} - -func NewPost() *Post { - return &Post{ - Schema: schema.PostSchema, - } -} - -func NewPostWith(obj Post) *Post { - m := &Post{ - Schema: schema.PostSchema, - } - m.Assign(obj) - - return m -} diff --git a/examples/starter/models/user.go b/examples/starter/models/user.go deleted file mode 100644 index 0c23d08..0000000 --- a/examples/starter/models/user.go +++ /dev/null @@ -1,167 +0,0 @@ -package models - -import ( - "context" - "database/sql" - "errors" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - goooerrors "github.com/version-1/gooo/pkg/datasource/orm/errors" - "github.com/version-1/gooo/pkg/presenter/jsonapi" - "github.com/version-1/gooo/pkg/schema" -) - -type User struct { - schema.Schema - // db related fields - ID uuid.UUID - Username string - Bio *string - Email string - CreatedAt time.Time - UpdatedAt time.Time - - // non-db related fields - Posts []Post -} - -func (obj User) Columns() []string { - return []string{"id", "username", "bio", "email", "created_at", "updated_at"} -} - -func (obj *User) Scan(rows scanner) error { - if err := rows.Scan(&obj.ID, &obj.Username, &obj.Bio, &obj.Email, &obj.CreatedAt, &obj.UpdatedAt); err != nil { - return err - } - - return nil -} - -func (obj *User) Destroy(ctx context.Context, qr queryer) error { - if obj.ID == uuid.Nil { - return ErrPrimaryKeyMissing - } - - query := "DELETE FROM users WHERE id = $1" - if _, err := qr.ExecContext(ctx, query, obj.ID); err != nil { - return err - } - - return nil -} - -func (obj *User) Find(ctx context.Context, qr queryer) error { - if obj.ID == uuid.Nil { - return ErrPrimaryKeyMissing - } - - query := "SELECT id, username, bio, email, created_at, updated_at FROM users 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 -} - -func (obj *User) Save(ctx context.Context, qr queryer) error { - if err := obj.validate(); err != nil { - return err - } - query := ` - INSERT INTO users (username, bio, email) VALUES ($1, $2, $3) - ON CONFLICT(id) DO UPDATE SET username = $1, bio = $2, email = $3, updated_at = NOW() - RETURNING id, username, bio, email, created_at, updated_at - ` - - row := qr.QueryRowContext(ctx, query, obj.Username, obj.Bio, obj.Email) - if err := obj.Scan(row); err != nil { - return err - } - - return nil -} - -func (obj *User) Assign(v User) { - obj.ID = v.ID - obj.Username = v.Username - obj.Bio = v.Bio - obj.Email = v.Email - obj.CreatedAt = v.CreatedAt - obj.UpdatedAt = v.UpdatedAt - obj.Posts = v.Posts -} - -func (obj User) validate() goooerrors.ValidationError { - validator := obj.Schema.Fields[1].Options.Validators[0] - if err := validator.Validate("Username")(obj.Username); err != nil { - return err - } - - validator = obj.Schema.Fields[1].Options.Validators[1] - if err := validator.Validate("Username")(obj.Username, obj.Email); err != nil { - return err - } - - validator = obj.Schema.Fields[3].Options.Validators[0] - if err := validator.Validate("Email")(obj.Email); err != nil { - return err - } - - validator = obj.Schema.Fields[3].Options.Validators[1] - if err := validator.Validate("Email")(obj.Email); err != nil { - return err - } - - return nil -} - -func (obj User) JSONAPISerialize() (string, error) { - lines := []string{ - fmt.Sprintf("\"id\": %s", jsonapi.Stringify(obj.ID)), - fmt.Sprintf("\"username\": %s", jsonapi.Stringify(obj.Username)), - fmt.Sprintf("\"bio\": %s", jsonapi.Stringify(obj.Bio)), - fmt.Sprintf("\"email\": %s", jsonapi.Stringify(obj.Email)), - fmt.Sprintf("\"created_at\": %s", jsonapi.Stringify(obj.CreatedAt)), - fmt.Sprintf("\"updated_at\": %s", jsonapi.Stringify(obj.UpdatedAt)), - } - return fmt.Sprintf("{\n%s\n}", strings.Join(lines, ", \n")), nil -} - -func (obj User) ToJSONAPIResource() (jsonapi.Resource, jsonapi.Resources) { - includes := &jsonapi.Resources{} - r := jsonapi.Resource{ - ID: jsonapi.Stringify(obj.ID), - Type: "user", - Attributes: obj, - Relationships: jsonapi.Relationships{}, - } - - relationships := jsonapi.RelationshipHasMany{} - for _, ele := range obj.Posts { - relationships.Data = append( - relationships.Data, - jsonapi.ResourceIdentifier{ - ID: jsonapi.Stringify(ele.ID), - Type: "post", - }, - ) - - resource, childIncludes := ele.ToJSONAPIResource() - includes.Append(resource) - includes.Append(childIncludes.Data...) - } - - if len(relationships.Data) > 0 { - r.Relationships["posts"] = relationships - } - return r, *includes -} diff --git a/examples/starter/schema/schema.go b/examples/starter/schema/schema.go deleted file mode 100644 index 1312163..0000000 --- a/examples/starter/schema/schema.go +++ /dev/null @@ -1,191 +0,0 @@ -package schema - -import ( - "path/filepath" - "strings" - - "github.com/version-1/gooo/pkg/datasource/orm/errors" - "github.com/version-1/gooo/pkg/datasource/orm/validator" - "github.com/version-1/gooo/pkg/schema" -) - -var UserSchema = schema.Schema{ - Name: "User", - TableName: "users", - Fields: []schema.Field{ - { - Name: "ID", - Type: schema.UUID, - Options: schema.FieldOptions{ - PrimaryKey: true, - Immutable: true, - }, - }, - { - Name: "Username", - Type: schema.String, - Options: schema.FieldOptions{ - Validators: []schema.Validator{ - { - Validate: validator.Required, - }, - { - Fields: []string{"Email"}, - Validate: func(key string) validator.ValidatorFunc { - return func(v ...any) errors.ValidationError { - username := v[0].(string) - email := strings.Split(v[1].(string), "@")[0] - if strings.Contains(username, email) { - return errors.NewValidationError(key, "Username should not contain email") - } - - return nil - } - }, - }, - }, - }, - }, - { - Name: "Bio", - Type: schema.Ref(schema.String), - Options: schema.FieldOptions{}, - }, - { - Name: "Email", - Type: schema.String, - Options: schema.FieldOptions{ - Validators: []schema.Validator{ - { - Validate: validator.Required, - }, - { - Validate: validator.Email, - }, - }, - }, - }, - { - Name: "CreatedAt", - Type: schema.Time, - Options: schema.FieldOptions{ - Immutable: true, - }, - }, - { - Name: "UpdatedAt", - Type: schema.Time, - Options: schema.FieldOptions{ - Immutable: true, - }, - }, - }, -} - -var PostSchema = schema.Schema{ - Name: "Post", - TableName: "posts", - Fields: []schema.Field{ - { - Name: "ID", - Type: schema.UUID, - Options: schema.FieldOptions{ - PrimaryKey: true, - Immutable: true, - }, - }, - { - Name: "UserID", - Type: schema.UUID, - Options: schema.FieldOptions{ - Validators: []schema.Validator{ - { - Validate: validator.Required, - }, - }, - }, - }, - { - Name: "Title", - Type: schema.String, - Options: schema.FieldOptions{ - Validators: []schema.Validator{ - { - Validate: validator.Required, - }, - }, - }, - }, - { - Name: "Body", - Type: schema.String, - Options: schema.FieldOptions{ - Validators: []schema.Validator{ - { - Validate: validator.Required, - }, - }, - }, - }, - { - Name: "Status", - Type: schema.String, - Options: schema.FieldOptions{}, - }, - { - Name: "CreatedAt", - Type: schema.Time, - Options: schema.FieldOptions{ - Immutable: true, - }, - }, - { - Name: "UpdatedAt", - Type: schema.Time, - Options: schema.FieldOptions{ - Immutable: true, - }, - }, - }, -} - -func Run(dir string) error { - UserSchema.AddFields(schema.Field{ - Name: "Posts", - Type: schema.Slice(PostSchema.Type()), - Options: schema.FieldOptions{ - Ignore: true, - Association: &schema.Association{ - Schema: PostSchema, - Slice: true, - }, - }, - }) - - PostSchema.AddFields(schema.Field{ - Name: "User", - Type: UserSchema.Type(), - Options: schema.FieldOptions{ - Ignore: true, - Association: &schema.Association{ - Schema: UserSchema, - }, - }, - }) - - schemas := schema.SchemaCollection{ - URL: "github.com/version-1/gooo", - Package: filepath.Base(dir), - Dir: dir, - Schemas: []schema.Schema{ - UserSchema, - PostSchema, - }, - } - - if err := schemas.Gen(); err != nil { - return err - } - - return nil -} diff --git a/go.mod b/go.mod index c87b6e6..23f1e3a 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,15 @@ require ( github.com/google/uuid v1.6.0 github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 - github.com/pkg/errors v0.9.1 + github.com/stretchr/testify v1.10.0 golang.org/x/tools v0.23.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect golang.org/x/mod v0.19.0 // indirect golang.org/x/sync v0.7.0 // indirect ) diff --git a/go.sum b/go.sum index 2d20ac9..2c17359 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= @@ -12,8 +14,12 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= diff --git a/pkg/app/app.go b/pkg/app/app.go deleted file mode 100644 index 2954971..0000000 --- a/pkg/app/app.go +++ /dev/null @@ -1,129 +0,0 @@ -package app - -import ( - gocontext "context" - "net/http" - "time" - - "github.com/version-1/gooo/pkg/config" - "github.com/version-1/gooo/pkg/context" - "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/logger" -) - -type Server struct { - Addr string - Config *config.App - ErrorHandler func(w *response.Response, r *request.Request, e error) - Handlers []controller.Handler - Middlewares []controller.Middleware -} - -func (s *Server) SetLogger(l logger.Logger) { - s.Config.Logger = l -} - -func (s Server) Logger() logger.Logger { - return s.Config.GetLogger() -} - -func (s *Server) RegisterHandlers(h ...controller.Handler) { - s.Handlers = append(s.Handlers, h...) -} - -func (s *Server) RegisterMiddlewares(m ...controller.Middleware) { - s.Middlewares = append(s.Middlewares, m...) -} - -func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - rr := &request.Request{ - Request: r, - } - ww := response.New( - w, - response.Options{ - Adapter: string(s.Config.DefaultResponseRenderer), - }, - ) - - for _, m := range s.Middlewares { - if m.If(rr) { - s.withRecover(m.String(), ww, rr, func() { - if next := m.Do(ww, rr); !next { - return - } - }) - } - } - - var target *controller.Handler - for _, handler := range s.Handlers { - if handler.Match(rr) { - target = &handler - break - } - } - - if target == nil { - http.NotFound(w, r) - return - } - - rr.Handler = target - s.withRecover(target.String(), ww, rr, func() { - if target.BeforeHandler != nil { - (*target.BeforeHandler)(ww, rr) - } - target.Handler(ww, rr) - }) -} - -func WithDefaultMiddlewares(s *Server) { - s.RegisterMiddlewares( - controller.WithContext( - func(r *request.Request) *request.Request { - ctx := r.Context() - ctx = context.WithAppConfig(ctx, s.Config) - - return r.WithContext(ctx) - }, - ), - controller.RequestBodyLogger(s.Logger()), - controller.RequestLogger(s.Logger()), - ) -} - -func (s Server) Run(ctx gocontext.Context) { - hs := &http.Server{ - Addr: s.Addr, - Handler: s, - ReadTimeout: 10 * time.Second, - WriteTimeout: 10 * time.Second, - MaxHeaderBytes: 1 << 20, - } - defer hs.Shutdown(ctx) - - s.Logger().Infof("Server is running on %s", s.Addr) - hs.ListenAndServe() -} - -func (s Server) WalkThrough(cb func(h controller.Handler)) { - for _, h := range s.Handlers { - cb(h) - } -} - -func (s Server) withRecover(spot string, w *response.Response, r *request.Request, fn func()) { - defer func() { - if e := recover(); e != nil { - s.Logger().Errorf("Caught panic on %s", spot) - if err, ok := e.(error); ok { - s.ErrorHandler(w, r, err) - } - } - }() - - fn() -} diff --git a/pkg/auth/helper.go b/pkg/auth/helper.go deleted file mode 100644 index a0ffb55..0000000 --- a/pkg/auth/helper.go +++ /dev/null @@ -1,16 +0,0 @@ -package auth - -import ( - "github.com/version-1/gooo/pkg/context" - "github.com/version-1/gooo/pkg/http/request" -) - -func SetContextOnAuthorized[T any](r *request.Request, sub string, fetcher func(sub string) (T, error)) error { - u, err := fetcher(sub) - if err != nil { - return err - } - - r.WithContext(context.WithUserConfig(r.Context(), u)) - return nil -} diff --git a/pkg/command/migration/adapter/yaml/schema.go b/pkg/command/migration/adapter/yaml/schema.go index 278851f..9fb33a5 100644 --- a/pkg/command/migration/adapter/yaml/schema.go +++ b/pkg/command/migration/adapter/yaml/schema.go @@ -7,7 +7,8 @@ import ( "strings" "github.com/version-1/gooo/pkg/command/migration/constants" - "github.com/version-1/gooo/pkg/db" + "github.com/version-1/gooo/pkg/datasource/db" + "github.com/version-1/gooo/pkg/toolkit/errors" yaml "gopkg.in/yaml.v3" ) @@ -35,7 +36,7 @@ type Column struct { Name string `yaml:"name"` Type string `yaml:"type"` Default *string `yaml:"default"` - Null *bool `yaml:"null"` + AllowNull *bool `yaml:"allow_null"` PrimaryKey *bool `yaml:"primary_key"` } @@ -45,7 +46,7 @@ func (c Column) Definition() string { s += fmt.Sprintf(" DEFAULT %s", *c.Default) } - if c.Null != nil && (*c.Null) == true { + if c.AllowNull != nil && (*c.AllowNull) == true { // do nothing } else { s += " NOT NULL" @@ -106,6 +107,10 @@ func (s *OriginSchema) Load(path string) error { return load(path, s) } +func (s *OriginSchema) Write(path string) error { + return write(path, s) +} + func (s *OriginSchema) Up(ctx context.Context, db db.Tx) error { for _, t := range s.Tables { if _, err := db.ExecContext(ctx, t.Query()); err != nil { @@ -163,12 +168,31 @@ func (s *RawSchema) Down(ctx context.Context, tx db.Tx) error { func load(path string, schema any) error { f, err := os.ReadFile(path) if err != nil { - return err + return errors.Wrap(err) } err = yaml.Unmarshal(f, schema) if err != nil { - return err + return errors.Wrap(err) + } + + return nil +} + +func write(path string, schema any) error { + f, err := os.Create(path) + if err != nil { + return errors.Wrap(err) + } + defer f.Close() + + b, err := yaml.Marshal(schema) + if err != nil { + return errors.Wrap(err) + } + + if _, err = f.Write(b); err != nil { + return errors.Wrap(err) } return nil diff --git a/pkg/command/migration/adapter/yaml/yaml.go b/pkg/command/migration/adapter/yaml/yaml.go index 0e82f65..4ae7b5d 100644 --- a/pkg/command/migration/adapter/yaml/yaml.go +++ b/pkg/command/migration/adapter/yaml/yaml.go @@ -6,7 +6,7 @@ import ( "github.com/version-1/gooo/pkg/command/migration/constants" "github.com/version-1/gooo/pkg/command/migration/helper" - "github.com/version-1/gooo/pkg/db" + "github.com/version-1/gooo/pkg/datasource/db" ) type YamlElement interface { diff --git a/pkg/command/migration/helper/helper.go b/pkg/command/migration/helper/helper.go index 8b28657..b6e33c3 100644 --- a/pkg/command/migration/helper/helper.go +++ b/pkg/command/migration/helper/helper.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" + goooerrors "github.com/version-1/gooo/pkg/toolkit/errors" + "github.com/version-1/gooo/pkg/command/migration/constants" ) @@ -14,7 +16,7 @@ func ParseKind(path string) (constants.MigrationKind, error) { if len(parts) < 3 { v, err := ParseVersion(path) if err != nil { - return "", err + return "", goooerrors.Wrap(err) } if v == strings.Repeat("0", 14) { @@ -46,7 +48,7 @@ func ParseVersion(path string) (string, error) { base := filepath.Base(path) parts := strings.Split(base, "_") if len(parts) < 2 && parts[0] != strings.Repeat("0", 14) { - return "", InvalidVersionError{path} + return "", goooerrors.Wrap(InvalidVersionError{path}) } return parts[0], nil diff --git a/pkg/command/migration/migration.go b/pkg/command/migration/migration.go index 4576a79..c1e6b4c 100644 --- a/pkg/command/migration/migration.go +++ b/pkg/command/migration/migration.go @@ -3,56 +3,86 @@ package migration import ( "context" "fmt" + "os" "strconv" + "strings" + "time" "github.com/jmoiron/sqlx" _ "github.com/lib/pq" "github.com/version-1/gooo/pkg/command/migration/constants" "github.com/version-1/gooo/pkg/command/migration/runner" - "github.com/version-1/gooo/pkg/db" - "github.com/version-1/gooo/pkg/logger" + "github.com/version-1/gooo/pkg/datasource/db" + goooerrors "github.com/version-1/gooo/pkg/toolkit/errors" + "github.com/version-1/gooo/pkg/toolkit/logger" ) -var _ Runner = (*runner.Base)(nil) var _ Runner = (*runner.Yaml)(nil) +type connector interface { + Connect() (*sqlx.DB, error) +} + type Command struct { - database string - conn *db.DB - runner Runner - version string - logger logger.Logger + database string + connector connector + conn *db.DB + runner Runner + version string + logger logger.Logger } type Runner interface { + Prepare(conn *sqlx.DB) error Up(ctx context.Context, db db.Tx, size int) error Down(ctx context.Context, db db.Tx, size int) error + BasePath() string + Elements() runner.Elements + Ext() string } -func NewWith(conn *sqlx.DB, runner Runner, l logger.Logger) (*Command, error) { +func NewWith(conn connector, runner Runner, l logger.Logger) (*Command, error) { _logger := l if _logger == nil { _logger = logger.DefaultLogger } - d := db.New(conn) c := &Command{ - conn: d, - runner: runner, - logger: _logger, + connector: conn, + runner: runner, + logger: _logger, + } + + return c, nil +} + +func (c *Command) connect() error { + conn, err := c.connector.Connect() + if err != nil { + return err } + + c.conn = db.New(conn) if err := c.prepare(); err != nil { - return nil, err + conn.Close() + return err + } + + if err := c.runner.Prepare(conn); err != nil { + conn.Close() + return err } database, err := c.Database() if err != nil { - return c, err + conn.Close() + return err } + c.logger.Infof("connecting database: %s", database) - _logger.Infof("connecting database: %s", database) + c.database = database - return c, nil + return nil } func (c Command) prepare() error { @@ -112,6 +142,17 @@ func (c Command) Exec(ctx context.Context, cmd string, args ...string) error { return "" } + shouldConnect, err := validateCmd(cmd) + if err != nil { + return err + } + + if shouldConnect { + if err := c.connect(); err != nil { + return goooerrors.Wrap(err) + } + } + switch cmd { case "create": return c.Create() @@ -145,20 +186,20 @@ func (c Command) Create() error { c.logger.Infof("Creating database: %s", c.database) q := "CREATE DATABASE IF NOT EXISTS " + c.database _, err := c.conn.Exec(q) - return err + return goooerrors.Wrap(err) } func (c Command) Drop() error { c.logger.Infof("Dropping database: %s", c.database) q := "DROP DATABASE IF EXISTS " + c.database _, err := c.conn.Exec(q) - return err + return goooerrors.Wrap(err) } func (c Command) Up(ctx context.Context, size int) error { tx, err := c.conn.BeginTx(ctx, nil) if err != nil { - return err + return goooerrors.Wrap(err) } defer func() { @@ -178,7 +219,7 @@ func (c Command) Up(ctx context.Context, size int) error { } if err := c.runner.Up(ctx, tx, size); err != nil { tx.Rollback() - return err + return goooerrors.Wrap(err) } return tx.Commit() @@ -187,7 +228,7 @@ func (c Command) Up(ctx context.Context, size int) error { func (c Command) Down(ctx context.Context, size int) error { tx, err := c.conn.BeginTx(ctx, nil) if err != nil { - return err + return goooerrors.Wrap(err) } defer func() { @@ -207,12 +248,60 @@ func (c Command) Down(ctx context.Context, size int) error { } if err := c.runner.Down(context.Background(), tx, size); err != nil { tx.Rollback() - return err + return goooerrors.Wrap(err) } return tx.Commit() } func (c Command) Generate(ctx context.Context, name string) error { - return fmt.Errorf("not implemented") + version := time.Now().Format("20060102150405") + filename := fmt.Sprintf("%s_%s.%s", version, name, c.runner.Ext()) + if name == "initial" { + filename = fmt.Sprintf("%s_%s.%s", strings.Repeat("0", 14), name, c.runner.Ext()) + } + + path := fmt.Sprintf("%s/%s", c.runner.BasePath(), filename) + if _, err := os.Stat(path); err == nil { + return goooerrors.Wrap(fmt.Errorf("migration already exists: %s", path)) + } + + c.logger.Infof("Generating migration path %s", path) + f, err := os.Create(path) + if err != nil { + return goooerrors.Wrap(err) + } + + defer f.Close() + return nil +} + +func validateCmd(cmd string) (bool, error) { + candidates := []string{ + "create", + "drop", + "up", + "down", + "g", + "generate", + } + + shouldNotConnect := []string{ + "g", + "generate", + } + + for _, c := range candidates { + if c == cmd { + for _, s := range shouldNotConnect { + if s == cmd { + return false, nil + } + } + + return true, nil + } + } + + return false, fmt.Errorf("invalid command: %s. expect: [%s]", cmd, strings.Join(candidates, "|")) } diff --git a/pkg/command/migration/reader/reader.go b/pkg/command/migration/reader/reader.go index c23c3d0..41f7e8b 100644 --- a/pkg/command/migration/reader/reader.go +++ b/pkg/command/migration/reader/reader.go @@ -8,7 +8,7 @@ import ( "strings" "github.com/version-1/gooo/pkg/command/migration/constants" - "github.com/version-1/gooo/pkg/db" + "github.com/version-1/gooo/pkg/datasource/db" yaml "gopkg.in/yaml.v3" ) @@ -32,7 +32,7 @@ type Column struct { Name string `yaml:"name" json:"name"` Type string `yaml:"type" json:"type"` Default *string `yaml:"default" json:"default"` - Null *bool `yaml:"null" json:"null"` + AllowNull *bool `yaml:"allow_null" json:"allow_null"` PrimaryKey *bool `yaml:"primary_key" json:"primary_key"` } @@ -92,10 +92,10 @@ func (r *SchemaReader) Read(ctx context.Context) error { if isNullable == "YES" { null := true - c.Null = &null + c.AllowNull = &null } else if isNullable == "NO" { null := false - c.Null = &null + c.AllowNull = &null } t.Columns = append(t.Columns, c) diff --git a/pkg/command/migration/reader/record.go b/pkg/command/migration/reader/record.go index 664175d..ef3bedc 100644 --- a/pkg/command/migration/reader/record.go +++ b/pkg/command/migration/reader/record.go @@ -7,7 +7,7 @@ import ( "time" "github.com/version-1/gooo/pkg/command/migration/constants" - "github.com/version-1/gooo/pkg/db" + "github.com/version-1/gooo/pkg/datasource/db" ) type Record struct { diff --git a/pkg/command/migration/runner/runner.go b/pkg/command/migration/runner/runner.go index 40195f6..cf8296a 100644 --- a/pkg/command/migration/runner/runner.go +++ b/pkg/command/migration/runner/runner.go @@ -6,8 +6,8 @@ import ( "github.com/version-1/gooo/pkg/command/migration/constants" "github.com/version-1/gooo/pkg/command/migration/reader" - "github.com/version-1/gooo/pkg/db" - "github.com/version-1/gooo/pkg/logger" + "github.com/version-1/gooo/pkg/datasource/db" + "github.com/version-1/gooo/pkg/toolkit/logger" ) type Base struct { @@ -38,6 +38,10 @@ func (r *Base) SetElements(elements Elements) { r.elements = elements } +func (r Base) Elements() Elements { + return r.elements +} + type Migration interface { Up(ctx context.Context, tx db.Tx) error Down(ctx context.Context, tx db.Tx) error diff --git a/pkg/command/migration/runner/yaml.go b/pkg/command/migration/runner/yaml.go index 03b650c..7df3c98 100644 --- a/pkg/command/migration/runner/yaml.go +++ b/pkg/command/migration/runner/yaml.go @@ -7,7 +7,7 @@ import ( "github.com/jmoiron/sqlx" "github.com/version-1/gooo/pkg/command/migration/adapter/yaml" - "github.com/version-1/gooo/pkg/db" + "github.com/version-1/gooo/pkg/datasource/db" ) type Yaml struct { @@ -15,34 +15,38 @@ type Yaml struct { pathGlob string } -func NewYaml(conn *sqlx.DB, pathGlob string) (*Yaml, error) { +func NewYaml(pathGlob string) (*Yaml, error) { + return &Yaml{ + pathGlob: pathGlob, + }, nil +} + +func (y *Yaml) Prepare(conn *sqlx.DB) error { r, err := New(db.New(conn)) if err != nil { - return nil, err + return err } - matches, err := filepath.Glob(pathGlob) + matches, err := filepath.Glob(y.pathGlob) if err != nil { - return nil, err + return err } files := make(Elements, len(matches)) for i, m := range matches { f, err := yaml.LoadFile(m) if err != nil { - return nil, err + return err } files[i] = *f } sort.Sort(&files) - r.SetElements(files) - return &Yaml{ - runner: r, - pathGlob: pathGlob, - }, nil + y.runner = r + r.SetElements(files) + return nil } func (y Yaml) Up(ctx context.Context, tx db.Tx, size int) error { @@ -52,3 +56,15 @@ func (y Yaml) Up(ctx context.Context, tx db.Tx, size int) error { func (y Yaml) Down(ctx context.Context, tx db.Tx, size int) error { return y.runner.Down(ctx, tx, size) } + +func (y Yaml) BasePath() string { + return filepath.Dir(y.pathGlob) +} + +func (y Yaml) Ext() string { + return "yaml" +} + +func (y Yaml) Elements() Elements { + return y.runner.Elements() +} diff --git a/pkg/command/seeder/seeder.go b/pkg/command/seeder/seeder.go index 19e627e..d8f33b8 100644 --- a/pkg/command/seeder/seeder.go +++ b/pkg/command/seeder/seeder.go @@ -7,7 +7,7 @@ import ( _ "github.com/lib/pq" "github.com/jmoiron/sqlx" - "github.com/version-1/gooo/pkg/logger" + "github.com/version-1/gooo/pkg/toolkit/logger" ) type SeedExecutor struct { diff --git a/pkg/config/config.go b/pkg/config/config.go deleted file mode 100644 index fa63153..0000000 --- a/pkg/config/config.go +++ /dev/null @@ -1,25 +0,0 @@ -package config - -import ( - "github.com/version-1/gooo/pkg/logger" -) - -type App struct { - Logger logger.Logger - DefaultResponseRenderer ResponseRenderer -} - -type ResponseRenderer string - -const ( - JSONAPIRenderer ResponseRenderer = "jsonapi" - RawRenderer ResponseRenderer = "raw" -) - -func (c App) GetLogger() logger.Logger { - if c.Logger == nil { - return logger.DefaultLogger - } - - return c.Logger -} diff --git a/pkg/context/context.go b/pkg/context/context.go deleted file mode 100644 index 56ac810..0000000 --- a/pkg/context/context.go +++ /dev/null @@ -1,36 +0,0 @@ -package context - -import ( - "context" - - "github.com/version-1/gooo/pkg/config" -) - -const ( - APP_CONFIG_KEY = "gooo:request:app_config" - USER_CONFIG_KEY = "gooo:request:user_config" -) - -func Get[T any](ctx context.Context, key string) T { - return ctx.Value(key).(T) -} - -func With[T any](ctx context.Context, key string, value T) context.Context { - return context.WithValue(ctx, key, value) -} - -func WithAppConfig(ctx context.Context, cfg *config.App) context.Context { - return With(ctx, APP_CONFIG_KEY, cfg) -} - -func AppConfig(ctx context.Context) *config.App { - return Get[*config.App](ctx, APP_CONFIG_KEY) -} - -func WithUserConfig[T any](ctx context.Context, u T) context.Context { - return With(ctx, USER_CONFIG_KEY, u) -} - -func UserConfig[T any](ctx context.Context) T { - return Get[T](ctx, USER_CONFIG_KEY) -} diff --git a/pkg/controller/handler.go b/pkg/controller/handler.go deleted file mode 100644 index a32aa26..0000000 --- a/pkg/controller/handler.go +++ /dev/null @@ -1,149 +0,0 @@ -package controller - -import ( - "fmt" - "path/filepath" - "strconv" - "strings" - - "github.com/version-1/gooo/pkg/http/request" - "github.com/version-1/gooo/pkg/http/response" -) - -type BeforeHandlerFunc func(*response.Response, *request.Request) bool -type HandlerFunc func(*response.Response, *request.Request) - -type GroupHandler struct { - Path string - Handlers []Handler -} - -func (g *GroupHandler) Add(h ...Handler) { - g.Handlers = append(g.Handlers, h...) -} - -func (g GroupHandler) List() []Handler { - list := make([]Handler, len(g.Handlers)) - for i, h := range g.Handlers { - h.Path = filepath.Clean(g.Path + h.Path) - list[i] = h - } - - return list -} - -type Handler struct { - Path string - Method string - BeforeHandler *BeforeHandlerFunc - Handler HandlerFunc -} - -func (h Handler) String() string { - return fmt.Sprintf("Handler [%s] %s", h.Method, h.Path) -} - -func (h Handler) Match(r *request.Request) bool { - if r.Request.Method != h.Method { - return false - } - - if r.Request.URL.Path == h.Path { - return true - } - - parts := strings.Split(h.Path, "/") - targetParts := strings.Split(r.Request.URL.Path, "/") - if len(parts) < len(targetParts) { - return false - } - - for i, part := range parts { - if !strings.HasPrefix(part, ":") && part != targetParts[i] { - return false - } - } - - return false -} - -func (h Handler) Param(url string, key string) (string, bool) { - search := ":" + key - if !strings.Contains(h.Path, search) { - return "", false - } - - parts := strings.Split(h.Path, "/") - index := -1 - for i, part := range parts { - if part == search { - index = i - break - } - } - - if index == -1 { - return "", false - } - - targetParts := strings.Split(url, "/") - if len(targetParts) < index { - return "", false - } - - return targetParts[index], true -} - -func (h Handler) ParamInt(url string, key string) (int, bool) { - v, ok := h.Param(url, key) - if !ok { - return 0, false - } - - n, err := strconv.Atoi(v) - if err != nil { - return 0, false - } - - return n, true -} - -func Post(path string, handler HandlerFunc) Handler { - return Handler{ - Path: path, - Method: "POST", - Handler: handler, - } -} - -func Get(path string, handler HandlerFunc) Handler { - return Handler{ - Path: path, - Method: "GET", - Handler: handler, - } -} - -func Put(path string, handler HandlerFunc) Handler { - return Handler{ - Path: path, - Method: "PUT", - Handler: handler, - } -} - -func Patch(path string, handler HandlerFunc) Handler { - return Handler{ - Path: path, - Method: "PATCH", - Handler: handler, - } -} - -func Delete(path string, handler HandlerFunc) Handler { - return Handler{ - Path: path, - Method: "DELETE", - Handler: handler, - } -} diff --git a/pkg/controller/middleware.go b/pkg/controller/middleware.go deleted file mode 100644 index bcad636..0000000 --- a/pkg/controller/middleware.go +++ /dev/null @@ -1,94 +0,0 @@ -package controller - -import ( - "bytes" - "fmt" - "io" - "net/http" - "strings" - - "github.com/version-1/gooo/pkg/http/request" - "github.com/version-1/gooo/pkg/http/response" - "github.com/version-1/gooo/pkg/logger" -) - -type Middleware struct { - Name string - If func(*request.Request) bool - Do func(*response.Response, *request.Request) bool -} - -func (m Middleware) String() string { - return fmt.Sprintf("Middleware %s", m.Name) -} - -func Always(r *request.Request) bool { - return true -} - -func RequestLogger(logger logger.Logger) Middleware { - return Middleware{ - If: Always, - Do: func(w *response.Response, r *request.Request) bool { - logger.Infof("%s %s", r.Request.Method, r.Request.URL.Path) - return true - }, - } -} - -func RequestBodyLogger(logger logger.Logger) Middleware { - return Middleware{ - If: Always, - Do: func(w *response.Response, r *request.Request) bool { - b, err := io.ReadAll(r.Request.Body) - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Internal server error")) - logger.Errorf("Error reading request body: %v", err) - return false - } - - io.Copy(w, io.MultiReader(bytes.NewReader(b), r.Request.Body)) - logger.Infof("body: %s", b) - return true - }, - } -} - -func RequestHeaderLogger(logger logger.Logger) Middleware { - return Middleware{ - If: Always, - Do: func(w *response.Response, r *request.Request) bool { - logger.Infof("HTTP Headers: ") - for k, v := range r.Request.Header { - logger.Infof("%s: %s", k, v) - } - return true - }, - } -} - -func CORS(origin, methods, headers []string) Middleware { - return Middleware{ - If: Always, - Do: func(w *response.Response, r *request.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 *request.Request) *request.Request) Middleware { - return Middleware{ - If: Always, - Do: func(w *response.Response, r *request.Request) bool { - for _, cb := range callbacks { - *r = *cb(r) - } - - return true - }, - } -} diff --git a/pkg/core/api/app/app.go b/pkg/core/api/app/app.go new file mode 100644 index 0000000..cf6bbc2 --- /dev/null +++ b/pkg/core/api/app/app.go @@ -0,0 +1,71 @@ +package app + +import ( + gocontext "context" + "net/http" + "time" + + "github.com/version-1/gooo/pkg/core/api/middleware" + "github.com/version-1/gooo/pkg/toolkit/errors" + "github.com/version-1/gooo/pkg/toolkit/logger" +) + +type App struct { + Addr string + Config *Config + ErrorHandler func(w http.ResponseWriter, r *http.Request, e error) + Middlewares middleware.Middlewares +} + +func (s *App) SetLogger(l logger.Logger) { + s.Config.logger = l +} + +func (s App) Logger() logger.Logger { + return s.Config.Logger() +} + +func (s App) ServeHTTP(w http.ResponseWriter, r *http.Request) { + for _, m := range s.Middlewares { + if m.If(r) { + s.withRecover(m.String(), w, r, func() { + if next := m.Do(w, r); !next { + return + } + }) + } + } +} + +func (s App) Run(ctx gocontext.Context) error { + hs := &http.Server{ + Addr: s.Addr, + Handler: s, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + MaxHeaderBytes: 1 << 20, + } + + defer hs.Shutdown(ctx) + + s.Logger().Infof("App is running on %s", s.Addr) + return hs.ListenAndServe() +} + +func (s App) withRecover(spot string, w http.ResponseWriter, r *http.Request, fn func()) { + defer func() { + if e := recover(); e != nil { + s.Logger().Errorf("Caught panic on %s", spot) + if err, ok := e.(error); ok { + s.ErrorHandler(w, r, err) + } + + if v, ok := e.(string); ok { + err := errors.New(v) + s.ErrorHandler(w, r, err) + } + } + }() + + fn() +} diff --git a/pkg/core/api/app/config.go b/pkg/core/api/app/config.go new file mode 100644 index 0000000..5d846c9 --- /dev/null +++ b/pkg/core/api/app/config.go @@ -0,0 +1,21 @@ +package app + +import ( + "github.com/version-1/gooo/pkg/toolkit/logger" +) + +type Config struct { + logger logger.Logger +} + +func (c *Config) SetLogger(l logger.Logger) { + c.logger = l +} + +func (c Config) Logger() logger.Logger { + if c.logger == nil { + return logger.DefaultLogger + } + + return c.logger +} diff --git a/pkg/core/api/app/helper.go b/pkg/core/api/app/helper.go new file mode 100644 index 0000000..6bdf708 --- /dev/null +++ b/pkg/core/api/app/helper.go @@ -0,0 +1,34 @@ +package app + +import ( + "net/http" + + "github.com/version-1/gooo/pkg/core/api/context" + "github.com/version-1/gooo/pkg/core/api/middleware" + "github.com/version-1/gooo/pkg/core/api/route" + + helper "github.com/version-1/gooo/pkg/toolkit/middleware" +) + +func WithDefaultMiddlewares(a *App, handlers ...route.HandlerInterface) middleware.Middlewares { + _handlers := make([]helper.Handler, len(handlers)) + for i, h := range handlers { + _handlers[i] = h + } + a.Middlewares = middleware.Middlewares([]middleware.Middleware{ + helper.WithContext( + func(r *http.Request) *http.Request { + ctx := r.Context() + ctx = context.With(ctx, context.APP_CONFIG_KEY, a.Config) + + return r.WithContext(ctx) + }, + ), + helper.RequestLogger(a.Logger()), + helper.RequestBodyLogger(a.Logger()), + helper.RequestHandler(_handlers), + helper.ResponseLogger(a.Logger()), // TODO: not implemented + }) + + return a.Middlewares +} diff --git a/pkg/core/api/context/context.go b/pkg/core/api/context/context.go new file mode 100644 index 0000000..818c166 --- /dev/null +++ b/pkg/core/api/context/context.go @@ -0,0 +1,25 @@ +package context + +import ( + "context" + "errors" + "fmt" +) + +const ( + APP_CONFIG_KEY = "gooo:request:app_config" +) + +func Get[T any](ctx context.Context, key string) T { + v, ok := ctx.Value(key).(T) + if !ok { + err := errors.New(fmt.Sprintf("context value not found: %s", key)) + panic(err) + } + + return v +} + +func With[T any](ctx context.Context, key string, value T) context.Context { + return context.WithValue(ctx, key, value) +} diff --git a/pkg/core/api/middleware/middleware.go b/pkg/core/api/middleware/middleware.go new file mode 100644 index 0000000..cd5567a --- /dev/null +++ b/pkg/core/api/middleware/middleware.go @@ -0,0 +1,34 @@ +package middleware + +import ( + "fmt" + "net/http" +) + +type Middlewares []Middleware + +func (m *Middlewares) Append(mw ...Middleware) { + *m = append(*m, mw...) +} + +func (m *Middlewares) Prepend(mw ...Middleware) { + list := mw + for _, it := range *m { + list = append(list, it) + } + *m = list +} + +type Middleware struct { + Name string + If func(*http.Request) bool + Do func(http.ResponseWriter, *http.Request) bool +} + +func (m Middleware) String() string { + return fmt.Sprintf("Middleware %s", m.Name) +} + +func Always(r *http.Request) bool { + return true +} diff --git a/pkg/core/api/middleware/middleware_test.go b/pkg/core/api/middleware/middleware_test.go new file mode 100644 index 0000000..d1ee80d --- /dev/null +++ b/pkg/core/api/middleware/middleware_test.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "fmt" + "net/http" + "reflect" + "testing" +) + +func TestMiddleware(t *testing.T) { + mw := Middlewares{} + output := []string{} + + mw.Append(Middleware{ + Name: "mw1", + If: Always, + Do: func(w http.ResponseWriter, r *http.Request) bool { + output = append(output, "mw1") + return true + }, + }) + + mw.Append(Middleware{ + Name: "mw2", + If: Always, + Do: func(w http.ResponseWriter, r *http.Request) bool { + output = append(output, "mw2") + return true + }, + }) + + mw.Append(Middleware{ + Name: "mw3", + If: Always, + Do: func(w http.ResponseWriter, r *http.Request) bool { + output = append(output, "mw3") + return true + }, + }) + + mw.Prepend(Middleware{ + Name: "mw5", + If: Always, + Do: func(w http.ResponseWriter, r *http.Request) bool { + output = append(output, "mw5") + return true + }, + }) + + expect := []string{"mw5", "mw1", "mw4", "mw2", "mw3"} + if !reflect.DeepEqual(output, expect) { + fmt.Printf("order of middlewares is incorrect. expect %v, got %v", expect, output) + } +} diff --git a/pkg/core/api/request/query.go b/pkg/core/api/request/query.go new file mode 100644 index 0000000..0466ea3 --- /dev/null +++ b/pkg/core/api/request/query.go @@ -0,0 +1,33 @@ +package request + +import ( + "net/url" + "strconv" + + "github.com/version-1/gooo/pkg/toolkit/logger" +) + +type Query struct { + url url.URL + logger logger.Logger +} + +func (q Query) GetString(key string) (string, bool) { + v := q.url.Query().Get(key) + return v, v != "" +} + +func (q Query) GetInt(key string) (int, bool) { + v := q.url.Query().Get(key) + if v == "" { + return 0, false + } + + i, err := strconv.Atoi(v) + if err != nil { + q.logger.Errorf("failed to convert query param %s to int: %s", key, err) + return 0, false + } + + return i, true +} diff --git a/pkg/core/api/request/request.go b/pkg/core/api/request/request.go new file mode 100644 index 0000000..f03dc1c --- /dev/null +++ b/pkg/core/api/request/request.go @@ -0,0 +1,74 @@ +package request + +import ( + gocontext "context" + "encoding/json" + "io" + "net/http" + + "github.com/version-1/gooo/pkg/core/api/context" + "github.com/version-1/gooo/pkg/toolkit/logger" +) + +type Void struct{} + +type Params interface { + GetString(key string) (string, error) + GetInt(key string) (int, error) + GetBool(key string) (bool, error) +} + +type Request[I any] struct { + params Params + *http.Request + body *[]byte + query Query +} + +func New[I any](r *http.Request, p Params) *Request[I] { + return &Request[I]{ + Request: r, + } +} + +func (r *Request[I]) Body() (I, error) { + var res I + if r.body == nil { + b, err := io.ReadAll(r.Request.Body) + if err != nil { + r.Logger().Errorf("failed to read request body: %s", err) + return res, err + } + + r.body = &b + } + + if err := json.Unmarshal(*r.body, &res); err != nil { + r.Logger().Errorf("failed to unmarshal request body: %s", err) + return res, nil + } + + return res, nil +} + +type loggerGetter interface { + Logger() logger.Logger +} + +func (r Request[I]) Logger() logger.Logger { + cfg := context.Get[loggerGetter](r.Request.Context(), context.APP_CONFIG_KEY) + return cfg.Logger() +} + +func (r Request[I]) Params() Params { + return r.params +} + +func (r Request[I]) Query() Query { + return r.query +} + +func (r *Request[I]) WithContext(ctx gocontext.Context) *Request[I] { + r.Request = r.Request.WithContext(ctx) + return r +} diff --git a/pkg/core/api/response/adapter.go b/pkg/core/api/response/adapter.go new file mode 100644 index 0000000..739b872 --- /dev/null +++ b/pkg/core/api/response/adapter.go @@ -0,0 +1,85 @@ +package response + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type JSONAdapter struct{} + +func (a JSONAdapter) Render(w http.ResponseWriter, payload any, status int) error { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(status) + return json.NewEncoder(w).Encode(payload) +} + +func (a JSONAdapter) Error(w http.ResponseWriter, err error, status int) { + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(status) + + _err := json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + if _err != nil { + panic(_err) + } +} + +type HTMLAdapter struct{} + +func (a HTMLAdapter) Render(w http.ResponseWriter, payload any, status int) error { + w.Header().Add("Content-Type", "text/html") + 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 HTMLAdapter) Error(w http.ResponseWriter, err error, status int) { + w.Header().Add("Content-Type", "text/html") + w.WriteHeader(status) + + body := []byte(fmt.Sprintf(` + + +

Error: %s

+

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) + } +}