diff --git a/README.md b/README.md index 652b7fee9..9a9fa9619 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ See `go-blueprint create -h` for all the options and shorthands. - [HttpRouter](https://github.com/julienschmidt/httprouter) - [Gorilla/mux](https://github.com/gorilla/mux) - [Echo](https://github.com/labstack/echo) +- [Huma](https://github.com/danielgtaylor/huma) (built on Chi, with OpenAPI 3.1 docs and request/response validation) diff --git a/cmd/flags/frameworks.go b/cmd/flags/frameworks.go index d1d7b59b8..85c71b61f 100644 --- a/cmd/flags/frameworks.go +++ b/cmd/flags/frameworks.go @@ -18,9 +18,10 @@ const ( HttpRouter Framework = "httprouter" StandardLibrary Framework = "standard-library" Echo Framework = "echo" + Huma Framework = "huma" ) -var AllowedProjectTypes = []string{string(Chi), string(Gin), string(Fiber), string(GorillaMux), string(HttpRouter), string(StandardLibrary), string(Echo)} +var AllowedProjectTypes = []string{string(Chi), string(Gin), string(Fiber), string(GorillaMux), string(HttpRouter), string(StandardLibrary), string(Echo), string(Huma)} func (f Framework) String() string { return string(f) diff --git a/cmd/program/program.go b/cmd/program/program.go index a615b5aa9..abc168bef 100644 --- a/cmd/program/program.go +++ b/cmd/program/program.go @@ -98,6 +98,7 @@ var ( ginPackage = []string{"github.com/gin-gonic/gin"} fiberPackage = []string{"github.com/gofiber/fiber/v2"} echoPackage = []string{"github.com/labstack/echo/v4", "github.com/labstack/echo/v4/middleware"} + humaPackage = []string{"github.com/danielgtaylor/huma/v2", "github.com/danielgtaylor/huma/v2/adapters/humachi", "github.com/go-chi/chi/v5"} mysqlDriver = []string{"github.com/go-sql-driver/mysql"} postgresDriver = []string{"github.com/jackc/pgx/v5/stdlib"} @@ -185,6 +186,11 @@ func (p *Project) createFrameworkMap() { packageName: echoPackage, templater: framework.EchoTemplates{}, } + + p.FrameworkMap[flags.Huma] = Framework{ + packageName: humaPackage, + templater: framework.HumaTemplates{}, + } } func (p *Project) createDBDriverMap() { diff --git a/cmd/steps/steps.go b/cmd/steps/steps.go index eacb874bc..79f4b2534 100644 --- a/cmd/steps/steps.go +++ b/cmd/steps/steps.go @@ -59,6 +59,10 @@ func InitSteps(projectType flags.Framework, databaseType flags.Database) *Steps Title: "Echo", Desc: "High performance, extensible, minimalist Go web framework", }, + { + Title: "Huma", + Desc: "A modern, simple to use, and fast REST API framework with built-in OpenAPI 3.1 docs and validation (built on Chi)", + }, }, Headers: "What framework do you want to use in your Go project?", Field: projectType.String(), diff --git a/cmd/template/framework/files/routes/huma.go.tmpl b/cmd/template/framework/files/routes/huma.go.tmpl new file mode 100644 index 000000000..5c4ffa2e0 --- /dev/null +++ b/cmd/template/framework/files/routes/huma.go.tmpl @@ -0,0 +1,103 @@ +package server + +import ( + "context" + "net/http" + {{if .AdvancedOptions.websocket}} + "fmt" + "log" + "time" + {{end}} + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/adapters/humachi" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + {{.AdvancedTemplates.TemplateImports}} + +) + +type HelloWorldOutput struct { + Body struct { + Message string `json:"message" example:"Hello World" doc:"Greeting message"` + } +} + +{{if ne .DBDriver "none"}} +type HealthOutput struct { + Body map[string]string +} +{{end}} + +func (s *Server) RegisterRoutes() http.Handler { + r := chi.NewRouter() + r.Use(middleware.Logger) + + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: []string{"https://*", "http://*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, + AllowCredentials: true, + MaxAge: 300, + })) + + api := humachi.New(r, huma.DefaultConfig("{{.ProjectName}}", "1.0.0")) + + huma.Register(api, huma.Operation{ + OperationID: "get-hello-world", + Method: http.MethodGet, + Path: "/", + Summary: "Hello World", + }, func(ctx context.Context, input *struct{}) (*HelloWorldOutput, error) { + resp := &HelloWorldOutput{} + resp.Body.Message = "Hello World" + return resp, nil + }) + {{if ne .DBDriver "none"}} + + huma.Register(api, huma.Operation{ + OperationID: "get-health", + Method: http.MethodGet, + Path: "/health", + Summary: "Health check", + }, func(ctx context.Context, input *struct{}) (*HealthOutput, error) { + resp := &HealthOutput{} + resp.Body = s.db.Health() + return resp, nil + }) + {{end}} + {{if .AdvancedOptions.websocket}} + r.Get("/websocket", s.websocketHandler) + {{end}} + {{.AdvancedTemplates.TemplateRoutes}} + + return r +} + +{{if .AdvancedOptions.websocket}} +func (s *Server) websocketHandler(w http.ResponseWriter, r *http.Request) { + socket, err := websocket.Accept(w, r, nil) + + if err != nil { + log.Printf("could not open websocket: %v", err) + _, _ = w.Write([]byte("could not open websocket")) + w.WriteHeader(http.StatusInternalServerError) + return + } + + defer socket.Close(websocket.StatusGoingAway, "server closing websocket") + + ctx := r.Context() + socketCtx := socket.CloseRead(ctx) + + for { + payload := fmt.Sprintf("server timestamp: %d", time.Now().UnixNano()) + err := socket.Write(socketCtx, websocket.MessageText, []byte(payload)) + if err != nil { + break + } + time.Sleep(time.Second * 2) + } +} +{{end}} diff --git a/cmd/template/framework/files/tests/huma-test.go.tmpl b/cmd/template/framework/files/tests/huma-test.go.tmpl new file mode 100644 index 000000000..e57770d73 --- /dev/null +++ b/cmd/template/framework/files/tests/huma-test.go.tmpl @@ -0,0 +1,40 @@ +package server + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHelloWorldHandler(t *testing.T) { + s := &Server{} + server := httptest.NewServer(s.RegisterRoutes()) + defer server.Close() + + resp, err := http.Get(server.URL + "/") + if err != nil { + t.Fatalf("error making request to server. Err: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("expected status OK; got %v", resp.Status) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("error reading response body. Err: %v", err) + } + + var result map[string]string + if err := json.Unmarshal(body, &result); err != nil { + t.Fatalf("error unmarshalling response body. Err: %v", err) + } + + expected := "Hello World" + if result["message"] != expected { + t.Errorf("expected message to be %v; got %v", expected, result["message"]) + } +} diff --git a/cmd/template/framework/humaRoutes.go b/cmd/template/framework/humaRoutes.go new file mode 100644 index 000000000..c949895cb --- /dev/null +++ b/cmd/template/framework/humaRoutes.go @@ -0,0 +1,45 @@ +package framework + +import ( + _ "embed" + + "github.com/melkeydev/go-blueprint/cmd/template/advanced" +) + +//go:embed files/routes/huma.go.tmpl +var humaRoutesTemplate []byte + +//go:embed files/tests/huma-test.go.tmpl +var humaTestHandlerTemplate []byte + +// HumaTemplates contains the methods used for building +// an app that uses [github.com/danielgtaylor/huma] on top of [github.com/go-chi/chi] +type HumaTemplates struct{} + +func (h HumaTemplates) Main() []byte { + return mainTemplate +} + +func (h HumaTemplates) Server() []byte { + return standardServerTemplate +} + +func (h HumaTemplates) Routes() []byte { + return humaRoutesTemplate +} + +func (h HumaTemplates) TestHandler() []byte { + return humaTestHandlerTemplate +} + +func (h HumaTemplates) HtmxTemplImports() []byte { + return advanced.StdLibHtmxTemplImportsTemplate() +} + +func (h HumaTemplates) HtmxTemplRoutes() []byte { + return advanced.ChiHtmxTemplRoutesTemplate() +} + +func (h HumaTemplates) WebsocketImports() []byte { + return advanced.StdLibWebsocketTemplImportsTemplate() +}