From 8f20fbf9f64447faf8a8a4b6d49ee9630dfc66a2 Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:03:20 -0700 Subject: [PATCH 1/5] Snapshot what generate writes, from packages loaded in memory generate's own tests covered a few percent of it: what a handler looks like for a form struct, an sse route, a status code in the name or a redirect in the template was only stated by the integration suite, which compiles and runs every scratch module and takes over a minute. internal/load/loadtest builds what a package load returns without loading the package graph: it writes the files, type checks them in memory, and imports the official standard library from the export data go list reports for it, reading it with gcexportdata -- as go/packages does -- and giving each package the imports go list reports, which is how check finds fmt behind html/template. The standard library is whichever the go command in use provides. A test binary pays a few hundred milliseconds once to read it. TestStandardLibrary states load.StandardLibrary's answers against it, and TestHydration the order load's hydration reports a missing package, a missing receiver and a variable that does not evaluate. internal/generate/testdata/generate/*.txtar holds one case per feature: the receiver's Go source, the templates, and the files and log lines generation produces. An archive holds the configuration it generates with, in its own config.json, beside the command line that parses into it; nothing outside the archive says what a case is. The directory an archive is in names the command, so a case runs alone as -run TestSnapshots/generate/sse. Reading it is encoding/json/v2, which the module now asks for Go 1.27 to have. TestSnapshots loads the case through loadtest and load.GenerateSource, as muxt generate does, generates, and compares; the 32 cases cover most of the package. It also fails on a generated file that imports a package it does not use. go test -run TestSnapshots -update rewrites the want/ files, and the diff is the review. A snapshot says what the generator does, not that the result compiles or serves requests; that stays the integration suite's job. Assisted-by: Claude:claude-opus-5 gofumpt --- CLAUDE.md | 13 +- go.mod | 2 +- internal/cli/configurations_test.go | 17 + internal/configjson/configjson.go | 23 + internal/generate/generated_test.go | 38 ++ internal/generate/snapshot_test.go | 250 ++++++++++ .../generate/err_duplicate_pattern.txtar | 40 ++ .../testdata/generate/err_name_errors.txtar | 49 ++ .../testdata/generate/err_resolution.txtar | 61 +++ ...esponse_state_with_response_argument.txtar | 44 ++ .../err_route_paths_method_collision.txtar | 41 ++ .../err_signals_without_datastar.txtar | 37 ++ .../testdata/generate/execute_callback.txtar | 229 +++++++++ .../testdata/generate/flag_custom_names.txtar | 191 ++++++++ .../testdata/generate/flag_htmx.txtar | 246 ++++++++++ .../flag_logger_path_prefix_middleware.txtar | 219 +++++++++ .../generate/flag_multiple_files.txtar | 310 ++++++++++++ .../flag_unexported_identifiers.txtar | 369 +++++++++++++++ .../generate/flag_without_muxt_version.txtar | 166 +++++++ .../testdata/generate/form_struct.txtar | 260 ++++++++++ .../testdata/generate/form_values.txtar | 223 +++++++++ .../testdata/generate/inferred_methods.txtar | 252 ++++++++++ .../testdata/generate/last_event_id.txtar | 186 ++++++++ .../testdata/generate/marshal_json.txtar | 203 ++++++++ .../testdata/generate/multipart.txtar | 241 ++++++++++ .../testdata/generate/nested_calls.txtar | 240 ++++++++++ .../generate/path_parameter_types.txtar | 443 ++++++++++++++++++ .../generate/receiver_method_sets.txtar | 250 ++++++++++ .../generate/testdata/generate/redirect.txtar | 250 ++++++++++ .../testdata/generate/request_body.txtar | 230 +++++++++ .../testdata/generate/response_argument.txtar | 172 +++++++ .../testdata/generate/result_shapes.txtar | 286 +++++++++++ .../generate/route_without_call.txtar | 195 ++++++++ internal/generate/testdata/generate/sse.txtar | 372 +++++++++++++++ .../testdata/generate/sse_datastar.txtar | 408 ++++++++++++++++ .../sse_messages_without_datastar.txtar | 344 ++++++++++++++ .../testdata/generate/status_codes.txtar | 277 +++++++++++ .../generate/synthesized_method_note.txtar | 229 +++++++++ internal/load/hydrate_test.go | 80 ++++ internal/load/loadtest/loadtest.go | 228 +++++++++ internal/load/loadtest/loadtest_test.go | 64 +++ internal/load/stdlib_test.go | 86 ++++ 42 files changed, 7862 insertions(+), 2 deletions(-) create mode 100644 internal/configjson/configjson.go create mode 100644 internal/generate/generated_test.go create mode 100644 internal/generate/snapshot_test.go create mode 100644 internal/generate/testdata/generate/err_duplicate_pattern.txtar create mode 100644 internal/generate/testdata/generate/err_name_errors.txtar create mode 100644 internal/generate/testdata/generate/err_resolution.txtar create mode 100644 internal/generate/testdata/generate/err_response_state_with_response_argument.txtar create mode 100644 internal/generate/testdata/generate/err_route_paths_method_collision.txtar create mode 100644 internal/generate/testdata/generate/err_signals_without_datastar.txtar create mode 100644 internal/generate/testdata/generate/execute_callback.txtar create mode 100644 internal/generate/testdata/generate/flag_custom_names.txtar create mode 100644 internal/generate/testdata/generate/flag_htmx.txtar create mode 100644 internal/generate/testdata/generate/flag_logger_path_prefix_middleware.txtar create mode 100644 internal/generate/testdata/generate/flag_multiple_files.txtar create mode 100644 internal/generate/testdata/generate/flag_unexported_identifiers.txtar create mode 100644 internal/generate/testdata/generate/flag_without_muxt_version.txtar create mode 100644 internal/generate/testdata/generate/form_struct.txtar create mode 100644 internal/generate/testdata/generate/form_values.txtar create mode 100644 internal/generate/testdata/generate/inferred_methods.txtar create mode 100644 internal/generate/testdata/generate/last_event_id.txtar create mode 100644 internal/generate/testdata/generate/marshal_json.txtar create mode 100644 internal/generate/testdata/generate/multipart.txtar create mode 100644 internal/generate/testdata/generate/nested_calls.txtar create mode 100644 internal/generate/testdata/generate/path_parameter_types.txtar create mode 100644 internal/generate/testdata/generate/receiver_method_sets.txtar create mode 100644 internal/generate/testdata/generate/redirect.txtar create mode 100644 internal/generate/testdata/generate/request_body.txtar create mode 100644 internal/generate/testdata/generate/response_argument.txtar create mode 100644 internal/generate/testdata/generate/result_shapes.txtar create mode 100644 internal/generate/testdata/generate/route_without_call.txtar create mode 100644 internal/generate/testdata/generate/sse.txtar create mode 100644 internal/generate/testdata/generate/sse_datastar.txtar create mode 100644 internal/generate/testdata/generate/sse_messages_without_datastar.txtar create mode 100644 internal/generate/testdata/generate/status_codes.txtar create mode 100644 internal/generate/testdata/generate/synthesized_method_note.txtar create mode 100644 internal/load/hydrate_test.go create mode 100644 internal/load/loadtest/loadtest.go create mode 100644 internal/load/loadtest/loadtest_test.go create mode 100644 internal/load/stdlib_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 6a51584d..bdf3060f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,9 @@ use `internal/muxt/muxttest`, which builds the counterfeiter fake in source (`muxttest.StandInChecker(t, pkg)`, or `muxttest.NewChecker()` with `Binds`, `ParsesFromText` and `FormatsAsText`), so they state muxt's rules rather than one library version's shape. Regenerate the fake with -`go generate ./internal/muxt`. +`go generate ./internal/muxt`. Tests that need real types use +`internal/load/loadtest`, which type checks package source against the +official standard library's export data without loading the package graph. **Key concept:** Muxt reads template names like `"GET /{id} GetUser(ctx, id)"` and generates `http.Handler` implementations that: - Parse URL parameters to the correct Go types @@ -108,6 +110,14 @@ Before adding an integration script, see whether a unit test can state it, at the layer that owns the behavior: - **Flags:** `internal/cli/configurations_test.go` states what a command line parses into, and which command lines are rejected, without loading anything. +- **What a command does with a valid configuration:** + `internal/generate/testdata/generate/*.txtar` snapshot generated files from + packages loaded in memory in milliseconds. The directory names the command; + each archive's `config.json` is the configuration the command line in its + header parses into. Run one with + `go test ./internal/generate -run TestSnapshots/generate/sse`, and rewrite + them with `go test ./internal/generate -run TestSnapshots -update`, then + review the diff. Integration scripts are for what needs the go command: generated code compiling and serving requests, and files on disk. @@ -182,6 +192,7 @@ ls cmd/muxt/testdata/err_*.txt - `internal/analysis/` — `muxt check` and the template listings - `internal/muxt/muxtfakes/` — The counterfeiter fake of `muxt.Checker`, generated by `go generate ./internal/muxt` - `internal/muxt/muxttest/` — Builds that fake from what a test says the standard library looks like, plus import-free type checking +- `internal/load/loadtest/` — A loaded package type checked against the official standard library, for tests that go through `internal/load` - `internal/cli/` — Command-line interface - `cmd/muxt/` — Command entry point diff --git a/go.mod b/go.mod index 2efbcaee..8858bdc1 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/typelate/muxt -go 1.26.0 +go 1.27.0 require ( github.com/dustin/go-humanize v1.0.1 diff --git a/internal/cli/configurations_test.go b/internal/cli/configurations_test.go index 3123d179..87fc3b09 100644 --- a/internal/cli/configurations_test.go +++ b/internal/cli/configurations_test.go @@ -215,6 +215,23 @@ func TestCommandLineConfigurations(t *testing.T) { OutputMuxtVersion: true, }, }, + { + name: "unexported default identifiers with a receiver type", + args: "generate --use-receiver-type=Server --output-exported-default-identifiers=false", + want: generate.RoutesFileConfiguration{ + MuxtVersion: "v1.2.3", + PackageName: "main", + RoutesFunction: "templateRoutes", + ReceiverType: "Server", + ReceiverInterface: "routesReceiver", + TemplateDataType: "templateData", + SSETemplateDataType: "sseTemplateData", + TemplateRoutePathsTypeName: "templateRoutePaths", + TemplatesVariables: []string{"templates"}, + OutputFileName: "template_routes.go", + OutputMuxtVersion: true, + }, + }, { name: "unexported default identifiers keep an explicit name", args: "generate --output-exported-default-identifiers=false --output-routes-func=Routes", diff --git a/internal/configjson/configjson.go b/internal/configjson/configjson.go new file mode 100644 index 00000000..2ed664f7 --- /dev/null +++ b/internal/configjson/configjson.go @@ -0,0 +1,23 @@ +// Package configjson says how muxt reads and writes a command's +// configuration as JSON. +// +// It is how the snapshot archives in internal/{generate,analysis,mutation} +// hold the configuration they run with, so a configuration read back is the +// one a command line produced: a field a command line left alone is null +// rather than an empty list, and a member the configuration does not +// declare is an error rather than a typo nothing reports. +// +// Patterns say nothing here: regexp.Regexp reads and writes itself as the +// text it was compiled from. +package configjson + +import "encoding/json/v2" + +// Options reads and writes a configuration the way muxt holds one. +func Options() json.Options { + return json.JoinOptions( + json.FormatNilSliceAsNull(true), + json.FormatNilMapAsNull(true), + json.RejectUnknownMembers(true), + ) +} diff --git a/internal/generate/generated_test.go b/internal/generate/generated_test.go new file mode 100644 index 00000000..efef5384 --- /dev/null +++ b/internal/generate/generated_test.go @@ -0,0 +1,38 @@ +package generate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileArguments(t *testing.T) { + dir := t.TempDir() + for name, content := range map[string]string{ + "template_routes.go": "// Code generated by muxt generate --use-receiver-type=T. DO NOT EDIT.\n\npackage server\n", + "index_gen.go": "// Code generated by muxt generate . DO NOT EDIT.\n", + "other_generated.go": "// Code generated by stringer. DO NOT EDIT.\n", + "handwritten.go": "package server\n", + "notes.txt": "// Code generated by muxt generate --output-file=notes.txt. DO NOT EDIT.\n", + "second_line.go": "package server\n// Code generated by muxt generate --output-htmx. DO NOT EDIT.\n", + "empty.go": "", + "sub/template_rou.go": "// Code generated by muxt generate --output-htmx. DO NOT EDIT.\n", + } { + path := filepath.Join(dir, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + } + + files, err := FileArguments(dir, DefaultRoutesFunctionName) + require.NoError(t, err) + assert.Equal(t, map[string][]string{ + filepath.Join(dir, "template_routes.go"): {"--use-receiver-type=T"}, + filepath.Join(dir, "index_gen.go"): {}, + }, files, "only the .go files in the directory itself whose first line is the muxt header") + + _, err = FileArguments(filepath.Join(dir, "missing"), DefaultRoutesFunctionName) + assert.Error(t, err) +} diff --git a/internal/generate/snapshot_test.go b/internal/generate/snapshot_test.go new file mode 100644 index 00000000..c2f11092 --- /dev/null +++ b/internal/generate/snapshot_test.go @@ -0,0 +1,250 @@ +package generate_test + +import ( + "encoding/json/v2" + "errors" + "flag" + "go/ast" + "go/parser" + "go/token" + "log" + "os" + "path" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + + "golang.org/x/tools/txtar" + + "github.com/typelate/muxt/internal/configjson" + "github.com/typelate/muxt/internal/generate" + "github.com/typelate/muxt/internal/load" + "github.com/typelate/muxt/internal/load/loadtest" + "github.com/typelate/muxt/internal/muxt" +) + +var update = flag.Bool("update", false, "rewrite the want/ files of the snapshot archives in testdata") + +// templatesGo declares the templates variable for an archive that does not +// declare its own: every template file, parsed as ParseFS would. +const templatesGo = `package server + +import ( + "embed" + "html/template" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) +` + +// TestSnapshots generates the routes files for each archive in +// testdata/generate and compares them with the archive's want/ files. The +// directory is the command, so one case runs with +// -run TestSnapshots/generate/sse. +// +// An archive holds everything a case is: the configuration it generates +// with, its inputs, and what it generates. +// +// - config.json is the configuration, as the command line named in the +// archive's header parses into it. TestCommandLineConfigurations in +// internal/cli states that parse; this states what generate does with +// the result. +// - Go files and .gohtml files are loaded as example.com/server by +// internal/load/loadtest -- type checked against the official standard +// library, without loading the package graph -- and hydrated by +// load.GenerateSource, as muxt generate does. An archive with no +// templates.go gets one declaring the templates variable over every +// .gohtml file. +// - want/ files are the expected output: one per generated file, named +// for it, and want/log.txt and want/error.txt for what generation +// logged and the error loading or generating returned. +// +// Paths in the output are relative to the directory the package was +// written to. +// +// Run with -update to rewrite the want/ files from the generator, then +// read the diff: the snapshot says what the generator does, not what it +// should do. Whether generated code compiles and serves requests is the +// integration suite's job, under cmd/muxt/testdata. +func TestSnapshots(t *testing.T) { + const command = "generate" + if stray, _ := filepath.Glob(filepath.Join("testdata", "*.txtar")); len(stray) > 0 { + t.Fatalf("%s is not in a command's directory; generate's archives are in testdata/%s", stray[0], command) + } + archives, err := filepath.Glob(filepath.Join("testdata", command, "*.txtar")) + if err != nil { + t.Fatal(err) + } + if len(archives) == 0 { + t.Fatalf("no archives in testdata/%s", command) + } + t.Run(command, func(t *testing.T) { + for _, archivePath := range archives { + runSnapshot(t, archivePath) + } + }) +} + +// runSnapshot compares one archive's want/ files with what it generates, +// or with -update rewrites them. +func runSnapshot(t *testing.T, archivePath string) { + t.Helper() + t.Run(strings.TrimSuffix(filepath.Base(archivePath), ".txtar"), func(t *testing.T) { + archive, err := txtar.ParseFile(archivePath) + if err != nil { + t.Fatal(err) + } + got := snapshot(t, configuration(t, archive), archive) + if *update { + writeSnapshot(t, archivePath, archive, got) + return + } + want := make(map[string]string) + for _, file := range archive.Files { + if name, ok := strings.CutPrefix(file.Name, "want/"); ok { + want[name] = string(file.Data) + } + } + for _, name := range sortedKeys(got, want) { + if got[name] != want[name] { + t.Errorf("want/%s differs (run go test -run TestSnapshots -update to rewrite):\n--- got\n%s\n--- want\n%s", name, got[name], want[name]) + } + } + }) +} + +// configuration reads the archive's config.json: the configuration to +// generate with. +func configuration(t *testing.T, archive *txtar.Archive) generate.RoutesFileConfiguration { + t.Helper() + for _, file := range archive.Files { + if file.Name != "config.json" { + continue + } + var config generate.RoutesFileConfiguration + if err := json.Unmarshal(file.Data, &config, configjson.Options()); err != nil { + t.Fatalf("config.json: %v", err) + } + return config + } + t.Fatal("the archive has no config.json") + return generate.RoutesFileConfiguration{} +} + +// snapshot loads an archive's package, generates from it, and returns the +// want/ files it produces, by name. +func snapshot(t *testing.T, config generate.RoutesFileConfiguration, archive *txtar.Archive) map[string]string { + t.Helper() + files := make(map[string]string) + for _, file := range archive.Files { + if strings.HasPrefix(file.Name, "want/") || file.Name == "config.json" { + continue + } + files[file.Name] = string(file.Data) + } + if _, declared := files["templates.go"]; !declared { + files["templates.go"] = templatesGo + } + + dir := t.TempDir() + relative := func(text string) string { return strings.ReplaceAll(text, dir+string(filepath.Separator), "") } + got := make(map[string]string) + fail := func(err error) map[string]string { + text := err.Error() + if multiLine, ok := errors.AsType[muxt.MultiLineError](err); ok { + text = multiLine.MultiLineError() + } + got["error.txt"] = relative(text) + "\n" + return got + } + + pl := loadtest.Package(t, dir, "example.com/server", files) + pkg, receiver, err := load.GenerateSource(dir, pl, config) + if err != nil { + return fail(err) + } + var logs strings.Builder + generated, err := generate.TemplateRoutesFiles(dir, config, pkg, receiver, load.StandardLibrary(pl), log.New(&logs, "", 0)) + for _, file := range generated { + got[relative(file.Path)] = file.Content + for _, name := range unusedImports(t, file.Content) { + t.Errorf("%s imports %s without using it", relative(file.Path), name) + } + } + if logs.Len() > 0 { + got["log.txt"] = relative(logs.String()) + } + if err != nil { + return fail(err) + } + return got +} + +func writeSnapshot(t *testing.T, archivePath string, archive *txtar.Archive, got map[string]string) { + t.Helper() + files := slices.DeleteFunc(slices.Clone(archive.Files), func(file txtar.File) bool { + return strings.HasPrefix(file.Name, "want/") + }) + for _, name := range sortedKeys(got) { + files = append(files, txtar.File{Name: "want/" + name, Data: []byte(got[name])}) + } + archive.Files = files + if err := os.WriteFile(archivePath, txtar.Format(archive), 0o644); err != nil { + t.Fatal(err) + } +} + +func sortedKeys(maps ...map[string]string) []string { + var keys []string + for _, m := range maps { + for key := range m { + if !slices.Contains(keys, key) { + keys = append(keys, key) + } + } + } + slices.Sort(keys) + return keys +} + +// unusedImports names the imports a generated file declares but does not +// refer to. Generated code that compiles has none; a file that did would +// be a generator registering an import for a declaration it did not write. +func unusedImports(t *testing.T, content string) []string { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "generated.go", content, 0) + if err != nil { + t.Fatal(err) + } + referenced := make(map[string]bool) + ast.Inspect(file, func(node ast.Node) bool { + if sel, ok := node.(*ast.SelectorExpr); ok { + // A package name resolves to nothing in the file; a local + // variable of the same name does. + if id, ok := sel.X.(*ast.Ident); ok && id.Obj == nil { + referenced[id.Name] = true + } + } + return true + }) + var unused []string + for _, spec := range file.Imports { + importPath, err := strconv.Unquote(spec.Path.Value) + if err != nil { + t.Fatal(err) + } + name := path.Base(importPath) + if spec.Name != nil { + name = spec.Name.Name + } + if !referenced[name] { + unused = append(unused, importPath) + } + } + return unused +} diff --git a/internal/generate/testdata/generate/err_duplicate_pattern.txtar b/internal/generate/testdata/generate/err_duplicate_pattern.txtar new file mode 100644 index 00000000..6ea98c9a --- /dev/null +++ b/internal/generate/testdata/generate/err_duplicate_pattern.txtar @@ -0,0 +1,40 @@ +Two templates may not register the same pattern. + +Command line: muxt generate +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /a"}}{{end}} +{{define "GET /a"}}{{end}} +-- server.go -- +package server +-- want/error.txt -- +duplicate route pattern "GET /a" +index.gohtml:1:11: first defined here +index.gohtml:2:11: also defined here diff --git a/internal/generate/testdata/generate/err_name_errors.txtar b/internal/generate/testdata/generate/err_name_errors.txtar new file mode 100644 index 00000000..741d0d17 --- /dev/null +++ b/internal/generate/testdata/generate/err_name_errors.txtar @@ -0,0 +1,49 @@ +Every malformed name is reported, each at its position. + +Command line: muxt generate +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "OPTIONS /a"}}{{end}} +{{define "GET /b//c"}}{{end}} +{{define "GET /{id}/{id} F(id)"}}{{end}} +-- server.go -- +package server +-- want/error.txt -- + OPTIONS /a + ^^^^^^^ +index.gohtml:1:11: OPTIONS method not allowed; allowed methods: GET, POST, PUT, PATCH, and DELETE + + GET /b//c + ^ +index.gohtml:2:18: path has an empty segment + + GET /{id}/{id} F(id) + ^^ +index.gohtml:3:22: path parameter name "id" is used more than once; parameter names must be unique within a path diff --git a/internal/generate/testdata/generate/err_resolution.txtar b/internal/generate/testdata/generate/err_resolution.txtar new file mode 100644 index 00000000..89bd24c0 --- /dev/null +++ b/internal/generate/testdata/generate/err_resolution.txtar @@ -0,0 +1,61 @@ +Resolution errors from every route are reported together, pointing at +the argument or method at fault, with where the method is defined. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{id} Float(id)"}}{{end}} +{{define "GET /ctx Context(request)"}}{{end}} +{{define "GET /none NoResults()"}}{{end}} +-- server.go -- +package server + +import "context" + +type T struct{} + +func (T) Float(id float64) string { return "" } +func (T) Context(ctx context.Context) string { return "" } +func (T) NoResults() {} +-- want/error.txt -- + GET /ctx Context(request) + ^^^^^^^ +index.gohtml:2:28: method expects type context.Context but request is *http.Request +server.go:8:10: Context is defined here + + GET /none NoResults() + ^^^^^^^^^ +index.gohtml:3:21: method NoResults() has no results; it should have one or two +server.go:9:10: NoResults is defined here + + GET /{id} Float(id) + ^^ +index.gohtml:1:27: method param type float64 not supported (supported: string, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, or a type whose pointer implements encoding.TextUnmarshaler; bind as string and parse it yourself for other values) +server.go:7:10: Float is defined here diff --git a/internal/generate/testdata/generate/err_response_state_with_response_argument.txtar b/internal/generate/testdata/generate/err_response_state_with_response_argument.txtar new file mode 100644 index 00000000..a5dd0b36 --- /dev/null +++ b/internal/generate/testdata/generate/err_response_state_with_response_argument.txtar @@ -0,0 +1,44 @@ +A template may not set the status of a route whose method took the +response. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /download Download(response)"}}{{.StatusCode 404}}{{end}} +-- server.go -- +package server + +import "net/http" + +type T struct{} + +func (T) Download(w http.ResponseWriter) string { return "" } +-- want/error.txt -- +index.gohtml:1:11: template "GET /download Download(response)" calls StatusCode but Download takes the http.ResponseWriter, so muxt writes no status code or redirect for this route: either drop the response argument or call response.WriteHeader in the method diff --git a/internal/generate/testdata/generate/err_route_paths_method_collision.txtar b/internal/generate/testdata/generate/err_route_paths_method_collision.txtar new file mode 100644 index 00000000..1f7bcf8e --- /dev/null +++ b/internal/generate/testdata/generate/err_route_paths_method_collision.txtar @@ -0,0 +1,41 @@ +Handlers whose names export to the same TemplateRoutePaths method +collide. + +Command line: muxt generate +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /a list()"}}{{end}} +{{define "GET /b List()"}}{{end}} +-- server.go -- +package server +-- want/error.txt -- +TemplateRoutePaths method name collision: handlers "list" and "List" both produce method "List" +index.gohtml:1:11: "list" is defined here +index.gohtml:2:11: "List" is defined here diff --git a/internal/generate/testdata/generate/err_signals_without_datastar.txtar b/internal/generate/testdata/generate/err_signals_without_datastar.txtar new file mode 100644 index 00000000..1623d484 --- /dev/null +++ b/internal/generate/testdata/generate/err_signals_without_datastar.txtar @@ -0,0 +1,37 @@ +signals is Datastar's request body, so it needs --output-datastar. + +Command line: muxt generate +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /count Count(signals)"}}{{end}} +-- server.go -- +package server +-- want/error.txt -- +the signals argument in "POST /count Count(signals)" requires --output-datastar; it is shorthand for unmarshalJSON(body) diff --git a/internal/generate/testdata/generate/execute_callback.txtar b/internal/generate/testdata/generate/execute_callback.txtar new file mode 100644 index 00000000..0e384bc9 --- /dev/null +++ b/internal/generate/testdata/generate/execute_callback.txtar @@ -0,0 +1,229 @@ +A method taking execute renders the template when it calls back, with +the data it passes or none. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /with-data Render(ctx, execute)"}}{{.Result}}{{end}} +{{define "GET /without-data Plain(execute)"}}{{end}} +-- server.go -- +package server + +import "context" + +type T struct{} + +func (T) Render(ctx context.Context, execute func(string) error) error { return nil } +func (T) Plain(execute func() error) error { return nil } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" + "sync/atomic" +) + +type RoutesReceiver interface { + Render(ctx context.Context, execute func(string) error) error + Plain(execute func() error) error +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /with-data", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + ctx := request.Context() + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + var executed atomic.Bool + if len(td.errList) == 0 { + if err := receiver.Render(ctx, func(data string) error { + if !executed.CompareAndSwap(false, true) { + return errors.New("execute callback called more than once") + } + td.result = data + return templates.ExecuteTemplate(buf, "GET /with-data Render(ctx, execute)", &td) + }); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + td.okay = true + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /without-data", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, struct{}]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + var executed atomic.Bool + if len(td.errList) == 0 { + if err := receiver.Plain(func() error { + if !executed.CompareAndSwap(false, true) { + return errors.New("execute callback called more than once") + } + return templates.ExecuteTemplate(buf, "GET /without-data Plain(execute)", &td) + }); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + td.okay = true + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Render() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "with-data") +} + +func (routePaths TemplateRoutePaths) Plain() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "without-data") +} diff --git a/internal/generate/testdata/generate/flag_custom_names.txtar b/internal/generate/testdata/generate/flag_custom_names.txtar new file mode 100644 index 00000000..ac49c725 --- /dev/null +++ b/internal/generate/testdata/generate/flag_custom_names.txtar @@ -0,0 +1,191 @@ +The output flags rename the generated file and identifiers. + +Command line: muxt generate --use-receiver-type=Server --output-file=routes.go --output-routes-func=Routes --output-receiver-interface=Handlers --output-template-data-type=Data --output-template-route-paths-type=Paths +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "Routes", + "ReceiverType": "Server", + "ReceiverPackage": "", + "ReceiverInterface": "Handlers", + "TemplateDataType": "Data", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "Paths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{id} Show(id)"}}{{.Result}}{{end}} +-- server.go -- +package server + +type Server struct{} + +func (*Server) Show(id string) string { return id } +-- want/routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "path" + "strconv" + "sync" +) + +type Handlers interface { + Show(id string) string +} + +func Routes(mux *http.ServeMux, receiver Handlers) Paths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /{id}", func(response http.ResponseWriter, request *http.Request) { + var td = Data[Handlers, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + idPathParam := request.PathValue("id") + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Show(idPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /{id} Show(id)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return Paths{pathsPrefix: pathsPrefix} +} + +type Data[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *Data[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *Data[R, T]) Path() Paths { + return Paths{pathsPrefix: data.pathsPrefix} +} + +func (data *Data[R, T]) Result() T { + return data.result +} + +func (data *Data[R, T]) Request() *http.Request { + return data.request +} + +func (data *Data[R, T]) StatusCode(statusCode int) *Data[R, T] { + data.statusCode = statusCode + return data +} + +func (data *Data[R, T]) Header(key, value string) *Data[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *Data[R, T]) Ok() bool { + return data.okay +} + +func (data *Data[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *Data[R, T]) Receiver() R { + return data.receiver +} + +func (data *Data[R, T]) Redirect(url string, code int) (*Data[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *Data[R, T]) RedirectMultipleChoices(url string) (*Data[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *Data[R, T]) RedirectMovedPermanently(url string) (*Data[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *Data[R, T]) RedirectFound(url string) (*Data[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *Data[R, T]) RedirectSeeOther(url string) (*Data[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *Data[R, T]) String() string { + return "" +} + +type Paths struct { + pathsPrefix string +} + +func (routePaths Paths) Show(idPathParam string) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), routePaths.escapePathSegment(idPathParam)) +} + +func (routePaths Paths) escapePathSegment(value string) string { + switch value { + case ".": + return "%2E" + case "..": + return "%2E%2E" + } + return url.PathEscape(value) +} diff --git a/internal/generate/testdata/generate/flag_htmx.txtar b/internal/generate/testdata/generate/flag_htmx.txtar new file mode 100644 index 00000000..eb63cc8f --- /dev/null +++ b/internal/generate/testdata/generate/flag_htmx.txtar @@ -0,0 +1,246 @@ +--output-htmx adds the HX header helpers to TemplateData. + +Command line: muxt generate --output-htmx +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": true, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{$}"}}home{{end}} +-- server.go -- +package server +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "sync" +) + +type RoutesReceiver interface { +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /{$}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, struct { + }]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if err := templates.ExecuteTemplate(buf, "GET /{$}", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +func (data *TemplateData[R, T]) HXLocation(link string) *TemplateData[R, T] { + return data.Header("HX-Location", link) +} + +func (data *TemplateData[R, T]) HXPushURL(link string) *TemplateData[R, T] { + return data.Header("HX-Push-Url", link) +} + +func (data *TemplateData[R, T]) HXRedirect(link string) *TemplateData[R, T] { + return data.Header("HX-Redirect", link) +} + +func (data *TemplateData[R, T]) HXRefresh() *TemplateData[R, T] { + return data.Header("HX-Refresh", "true") +} + +func (data *TemplateData[R, T]) HXReplaceURL(link string) *TemplateData[R, T] { + return data.Header("HX-Replace-Url", link) +} + +func (data *TemplateData[R, T]) HXReswap(swap string) *TemplateData[R, T] { + return data.Header("HX-Reswap", swap) +} + +func (data *TemplateData[R, T]) HXRetarget(target string) *TemplateData[R, T] { + return data.Header("HX-Retarget", target) +} + +func (data *TemplateData[R, T]) HXReselect(selector string) *TemplateData[R, T] { + return data.Header("HX-Reselect", selector) +} + +func (data *TemplateData[R, T]) HXTrigger(eventName string) *TemplateData[R, T] { + return data.Header("HX-Trigger", eventName) +} + +func (data *TemplateData[R, T]) HXTriggerAfterSettle(eventName string) *TemplateData[R, T] { + return data.Header("HX-Trigger-After-Settle", eventName) +} + +func (data *TemplateData[R, T]) HXTriggerAfterSwap(eventName string) *TemplateData[R, T] { + return data.Header("HX-Trigger-After-Swap", eventName) +} + +func (data *TemplateData[R, T]) HXBoosted() bool { + return data.Request().Header.Get("HX-Boosted") != "" +} + +func (data *TemplateData[R, T]) HXCurrentURL() string { + return data.Request().Header.Get("HX-Current-Url") +} + +func (data *TemplateData[R, T]) HXHistoryRestoreRequest() bool { + return data.Request().Header.Get("HX-History-Restore-Request") == "true" +} + +func (data *TemplateData[R, T]) HXPrompt() string { + return data.Request().Header.Get("HX-Prompt") +} + +func (data *TemplateData[R, T]) HXRequest() bool { + return data.Request().Header.Get("HX-Request") == "true" +} + +func (data *TemplateData[R, T]) HXTargetElementID() string { + return data.Request().Header.Get("HX-Target") +} + +func (data *TemplateData[R, T]) HXTriggerName() string { + return data.Request().Header.Get("HX-Trigger-Name") +} + +func (data *TemplateData[R, T]) HXTriggerElementID() string { + return data.Request().Header.Get("HX-Trigger") +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) ReadExact() string { + return "/" +} diff --git a/internal/generate/testdata/generate/flag_logger_path_prefix_middleware.txtar b/internal/generate/testdata/generate/flag_logger_path_prefix_middleware.txtar new file mode 100644 index 00000000..4b2adc3f --- /dev/null +++ b/internal/generate/testdata/generate/flag_logger_path_prefix_middleware.txtar @@ -0,0 +1,219 @@ +The routes function takes a logger, a path prefix, and a middleware. + +Command line: muxt generate --use-receiver-type=T --output-routes-func-with-logger-param --output-routes-func-with-path-prefix-param --output-routes-func-with-middleware-param +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": true, + "Logger": true, + "Middleware": true, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{$}"}}home{{end}} +{{define "GET /article/{id} Article(id)"}}{{.Result}}{{end}} +-- server.go -- +package server + +type T struct{} + +func (T) Article(id int) string { return "" } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Article(id int) string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver, logger *slog.Logger, pathsPrefix string, middleware func(next http.Handler) http.Handler) TemplateRoutePaths { + if middleware == nil { + middleware = func(next http.Handler) http.Handler { + return next + } + } + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.Handle("GET "+path.Join(pathsPrefix, "/article/{id}"), middleware(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + idPathParam := idParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Article(idPathParam) + td.okay = true + } + logger.DebugContext(request.Context(), "handling request", slog.String("pattern", "GET /article/{id}"), slog.String("path", request.URL.Path), slog.String("method", request.Method)) + if err := templates.ExecuteTemplate(buf, "GET /article/{id} Article(id)", &td); err != nil { + logger.ErrorContext(request.Context(), "failed to render page", slog.String("pattern", "GET /article/{id}"), slog.String("path", request.URL.Path), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }))) + mux.Handle("GET "+path.Join(pathsPrefix, "/{$}"), middleware(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, struct { + }]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + logger.DebugContext(request.Context(), "handling request", slog.String("pattern", "GET /{$}"), slog.String("path", request.URL.Path), slog.String("method", request.Method)) + if err := templates.ExecuteTemplate(buf, "GET /{$}", &td); err != nil { + logger.ErrorContext(request.Context(), "failed to render page", slog.String("pattern", "GET /{$}"), slog.String("path", request.URL.Path), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }))) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Article(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "article", strconv.Itoa(idPathParam)) +} + +func (routePaths TemplateRoutePaths) ReadExact() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/")) +} diff --git a/internal/generate/testdata/generate/flag_multiple_files.txtar b/internal/generate/testdata/generate/flag_multiple_files.txtar new file mode 100644 index 00000000..f6228cf9 --- /dev/null +++ b/internal/generate/testdata/generate/flag_multiple_files.txtar @@ -0,0 +1,310 @@ +--output-multiple-files writes the routes for each template file into +its own file, beside the main routes file. + +Command line: muxt generate --use-receiver-type=T --output-multiple-files --output-routes-func-with-middleware-param +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": true, + "Verbose": false, + "OutputMultipleFiles": true, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{$} Home()"}}{{.Result}}{{end}} +-- user-profile.gohtml -- +{{define "GET /user/{id} User(id)"}}{{.Result}}{{end}} +{{define "POST /user/{id} Save(id, form)"}}{{.Result}}{{end}} +-- server.go -- +package server + +import "net/url" + +type T struct{} + +func (T) Home() string { return "" } +func (T) User(id int) string { return "" } +func (T) Save(id int, form url.Values) error { return nil } +-- want/index_template_routes_gen.go -- +package server + +import ( + "bytes" + "cmp" + "log/slog" + "net/http" + "strconv" + "sync" +) + +type indexRoutesReceiver interface { + Home() string +} + +func indexTemplateRoutes(mux *http.ServeMux, receiver indexRoutesReceiver, pathsPrefix string, middleware func(next http.Handler) http.Handler) { + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.Handle("GET /{$}", middleware(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[indexRoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Home() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /{$} Home()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }))) +} +-- want/template_routes.go -- +package server + +import ( + "cmp" + "errors" + "fmt" + "net/http" + "path" + "strconv" +) + +type RoutesReceiver interface { + indexRoutesReceiver + userProfileRoutesReceiver +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver, middleware func(next http.Handler) http.Handler) TemplateRoutePaths { + pathsPrefix := "" + if middleware == nil { + middleware = func(next http.Handler) http.Handler { + return next + } + } + indexTemplateRoutes(mux, receiver, pathsPrefix, middleware) + userProfileTemplateRoutes(mux, receiver, pathsPrefix, middleware) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) User(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "user", strconv.Itoa(idPathParam)) +} + +func (routePaths TemplateRoutePaths) Save(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "user", strconv.Itoa(idPathParam)) +} + +func (routePaths TemplateRoutePaths) Home() string { + return "/" +} +-- want/user-profile_template_routes_gen.go -- +package server + +import ( + "bytes" + "cmp" + "log/slog" + "net/http" + "net/url" + "strconv" + "sync" +) + +type userProfileRoutesReceiver interface { + User(id int) string + Save(id int, form url.Values) error +} + +func userProfileTemplateRoutes(mux *http.ServeMux, receiver userProfileRoutesReceiver, pathsPrefix string, middleware func(next http.Handler) http.Handler) { + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.Handle("GET /user/{id}", middleware(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[userProfileRoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + idPathParam := idParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.User(idPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /user/{id} User(id)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }))) + mux.Handle("POST /user/{id}", middleware(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[userProfileRoutesReceiver, error]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + idPathParam := idParsed + if err := request.ParseForm(); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + var form url.Values = request.Form + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Save(idPathParam, form) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /user/{id} Save(id, form)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }))) +} diff --git a/internal/generate/testdata/generate/flag_unexported_identifiers.txtar b/internal/generate/testdata/generate/flag_unexported_identifiers.txtar new file mode 100644 index 00000000..9706973b --- /dev/null +++ b/internal/generate/testdata/generate/flag_unexported_identifiers.txtar @@ -0,0 +1,369 @@ +--output-exported-default-identifiers=false names the generated +identifiers unexported, as the command line spells them. + +Command line: muxt generate --use-receiver-type=Server --output-exported-default-identifiers=false +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "templateRoutes", + "ReceiverType": "Server", + "ReceiverPackage": "", + "ReceiverInterface": "routesReceiver", + "TemplateDataType": "templateData", + "SSETemplateDataType": "sseTemplateData", + "TemplateRoutePathsTypeName": "templateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": false, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /events sse(Stream(ctx, execute))"}}{{.Result}}{{end}} +{{define "GET /{$}"}}home{{end}} +-- server.go -- +package server + +import "context" + +type Server struct{} + +func (Server) Stream(ctx context.Context, execute func(data string) error) {} +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + "strconv" + "strings" + "sync" +) + +type routesReceiver interface { + Stream(ctx context.Context, execute func(data string) error) +} + +func templateRoutes(mux *http.ServeMux, receiver routesReceiver) templateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /events", func(response http.ResponseWriter, request *http.Request) { + defer func() { + _ = request.Body.Close() + }() + flusher, ok := response.(http.Flusher) + if !ok { + http.Error(response, "streaming unsupported", http.StatusInternalServerError) + return + } + ctx := request.Context() + h := response.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Connection", "keep-alive") + h.Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + flusher.Flush() + var mut sync.Mutex + receiver.Stream(ctx, func(result string) error { + if err := request.Context().Err(); err != nil { + return err + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + td := sseTemplateData[routesReceiver, string]{receiver: receiver, request: request, pathsPrefix: pathsPrefix, result: result} + if err := templates.ExecuteTemplate(buf, "GET /events sse(Stream(ctx, execute))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + return err + } + td.data = buf + mut.Lock() + defer mut.Unlock() + if _, err := td.WriteTo(response); err != nil { + return err + } + flusher.Flush() + return nil + }) + }) + mux.HandleFunc("GET /{$}", func(response http.ResponseWriter, request *http.Request) { + var td = templateData[routesReceiver, struct { + }]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if err := templates.ExecuteTemplate(buf, "GET /{$}", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return templateRoutePaths{pathsPrefix: pathsPrefix} +} + +type templateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *templateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *templateData[R, T]) Path() templateRoutePaths { + return templateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *templateData[R, T]) Result() T { + return data.result +} + +func (data *templateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *templateData[R, T]) StatusCode(statusCode int) *templateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *templateData[R, T]) Header(key, value string) *templateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *templateData[R, T]) Ok() bool { + return data.okay +} + +func (data *templateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *templateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *templateData[R, T]) Redirect(url string, code int) (*templateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *templateData[R, T]) RedirectMultipleChoices(url string) (*templateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *templateData[R, T]) RedirectMovedPermanently(url string) (*templateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *templateData[R, T]) RedirectFound(url string) (*templateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *templateData[R, T]) RedirectSeeOther(url string) (*templateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *templateData[R, T]) String() string { + return "" +} + +type sseTemplateData[R, T any] struct { + receiver R + request *http.Request + result T + pathsPrefix string + event, id *string + retryMilliseconds *int + errList []error + data *bytes.Buffer +} + +func (m *sseTemplateData[R, T]) String() string { + return "" +} + +func (m *sseTemplateData[R, T]) Receiver() R { + return m.receiver +} + +func (m *sseTemplateData[R, T]) Request() *http.Request { + return m.request +} + +func (m *sseTemplateData[R, T]) Result() T { + return m.result +} + +func (m *sseTemplateData[R, T]) Err() error { + return errors.Join(m.errList...) +} + +func (m *sseTemplateData[R, T]) Event(event string) *sseTemplateData[R, T] { + m.event = &event + return m +} + +func (m *sseTemplateData[R, T]) ID(id string) *sseTemplateData[R, T] { + m.id = &id + return m +} + +func (m *sseTemplateData[R, T]) Retry(retryMilliseconds int) *sseTemplateData[R, T] { + m.retryMilliseconds = &retryMilliseconds + return m +} + +func (m *sseTemplateData[R, T]) Path() templateRoutePaths { + return templateRoutePaths{pathsPrefix: m.pathsPrefix} +} + +func (m *sseTemplateData[R, T]) WriteTo(w io.Writer) (int64, error) { + if m.id != nil && strings.ContainsAny(*m.id, "\r\n\x00") { + return 0, errors.New("sse: id contains a forbidden character") + } + if m.event != nil && strings.ContainsAny(*m.event, "\r\n") { + return 0, errors.New("sse: event contains a forbidden character") + } + var bytesWritten int + if m.id != nil { + if n, err := io.WriteString(w, "id: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.id); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.event != nil { + if n, err := io.WriteString(w, "event: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.event); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.retryMilliseconds != nil { + if n, err := io.WriteString(w, "retry: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + var retryBuf [20]byte + if n, err := w.Write(strconv.AppendInt(retryBuf[:0], int64(*m.retryMilliseconds), 10)); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + data := m.data.Bytes() + if bytes.IndexByte(data, '\r') >= 0 { + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + data = bytes.ReplaceAll(data, []byte("\r"), []byte("\n")) + } + data = bytes.TrimSuffix(data, []byte{'\n'}) + for line := range bytes.SplitSeq(data, []byte{'\n'}) { + if n, err := io.WriteString(w, "data: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if len(line) > 0 { + if n, err := w.Write(line); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + return int64(bytesWritten), nil +} + +type templateRoutePaths struct { + pathsPrefix string +} + +func (routePaths templateRoutePaths) Stream() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "events") +} + +func (routePaths templateRoutePaths) ReadExact() string { + return "/" +} diff --git a/internal/generate/testdata/generate/flag_without_muxt_version.txtar b/internal/generate/testdata/generate/flag_without_muxt_version.txtar new file mode 100644 index 00000000..70ac0bce --- /dev/null +++ b/internal/generate/testdata/generate/flag_without_muxt_version.txtar @@ -0,0 +1,166 @@ +With --output-muxt-version=false, TemplateData has no MuxtVersion method, +so a template calling it does not compile. + +Command line: muxt generate --output-muxt-version=false +-- config.json -- +{ + "MuxtVersion": "", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": false, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{$}"}}{{.MuxtVersion}}{{end}} +-- server.go -- +package server +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "sync" +) + +type RoutesReceiver interface { +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /{$}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, struct { + }]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if err := templates.ExecuteTemplate(buf, "GET /{$}", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) ReadExact() string { + return "/" +} diff --git a/internal/generate/testdata/generate/form_struct.txtar b/internal/generate/testdata/generate/form_struct.txtar new file mode 100644 index 00000000..fae889f9 --- /dev/null +++ b/internal/generate/testdata/generate/form_struct.txtar @@ -0,0 +1,260 @@ +A form struct binds each field from the form, parsing scalars and +slices, renaming inputs with the name tag, and validating from the +input a template tag names. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /signup Signup(form)"}}{{end}} +{{define "signup-form"}} + + +{{end}} +-- server.go -- +package server + +type Signup struct { + Age int `name:"age" template:"signup-form"` + Handle string `name:"handle" template:"signup-form"` + Tags []string `name:"tag"` + Score float64 + Verified bool + Count uint32 +} + +type T struct{} + +func (T) Signup(form Signup) error { return nil } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "regexp" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Signup(form Signup) error +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("POST /signup", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, error]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + if err := request.ParseForm(); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + var form Signup + { + value, err := strconv.Atoi(request.FormValue("age")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } else { + if value < 13 { + td.errList = append(td.errList, errors.New("age must not be less than 13")) + td.errStatusCode = http.StatusBadRequest + } + if value > 130 { + td.errList = append(td.errList, errors.New("age must not be more than 130")) + td.errStatusCode = http.StatusBadRequest + } + } + form.Age = value + } + { + value := request.FormValue("handle") + if !regexp.MustCompile("[a-z]+").MatchString(value) { + td.errList = append(td.errList, errors.New("handle must match \"[a-z]+\"")) + td.errStatusCode = http.StatusBadRequest + } + if len(value) < 2 { + td.errList = append(td.errList, errors.New("handle is too short (the min length is 2)")) + td.errStatusCode = http.StatusBadRequest + } + if len(value) > 20 { + td.errList = append(td.errList, errors.New("handle is too long (the max length is 20)")) + td.errStatusCode = http.StatusBadRequest + } + form.Handle = value + } + for _, val := range request.Form["tag"] { + form.Tags = append(form.Tags, val) + } + { + value, err := strconv.ParseFloat(request.FormValue("Score"), 64) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + form.Score = value + } + { + value, err := strconv.ParseBool(request.FormValue("Verified")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + form.Verified = value + } + { + value, err := strconv.ParseUint(request.FormValue("Count"), 10, 32) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + form.Count = uint32(value) + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Signup(form) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /signup Signup(form)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Signup() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "signup") +} diff --git a/internal/generate/testdata/generate/form_values.txtar b/internal/generate/testdata/generate/form_values.txtar new file mode 100644 index 00000000..4a9f040c --- /dev/null +++ b/internal/generate/testdata/generate/form_values.txtar @@ -0,0 +1,223 @@ +A url.Values parameter receives the parsed form as it is. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /values Values(form)"}}{{end}} +{{define "POST /unmarshal Values(unmarshalForm(body))"}}{{end}} +-- server.go -- +package server + +import "net/url" + +type T struct{} + +func (T) Values(form url.Values) string { return "" } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Values(form url.Values) string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("POST /unmarshal", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + if err := request.ParseForm(); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + var form url.Values = request.Form + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Values(form) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /unmarshal Values(unmarshalForm(body))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("POST /values", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + if err := request.ParseForm(); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + var form url.Values = request.Form + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Values(form) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /values Values(form)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) CreateUnmarshalCallingValues() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "unmarshal") +} + +func (routePaths TemplateRoutePaths) CreateValuesCallingValues() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "values") +} diff --git a/internal/generate/testdata/generate/inferred_methods.txtar b/internal/generate/testdata/generate/inferred_methods.txtar new file mode 100644 index 00000000..3a376885 --- /dev/null +++ b/internal/generate/testdata/generate/inferred_methods.txtar @@ -0,0 +1,252 @@ +Without --use-receiver-type every called method is inferred from its +arguments, and returns any. + +Command line: muxt generate +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{id} Get(ctx, id)"}}{{.Result}}{{end}} +{{define "POST /upload Upload(request, response)"}}{{end}} +{{define "PATCH /note Note(form, lastEventID)"}}{{end}} +-- server.go -- +package server +-- want/log.txt -- +warning: POST /upload uses the response argument, so muxt does not manage this route's status codes, headers, or rendering; silence with MUXT_SILENCE_WARNING_HTTP_RESPONSE_ARGUMENT=true +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Note(form url.Values, lastEventID string) any + Upload(request *http.Request, response http.ResponseWriter) any + Get(ctx context.Context, id string) any +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("PATCH /note", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, any]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + if err := request.ParseForm(); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + var form url.Values = request.Form + lastEventID := request.Header.Get("Last-Event-Id") + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Note(form, lastEventID) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "PATCH /note Note(form, lastEventID)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("POST /upload", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, any]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Upload(request, response) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /upload Upload(request, response)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /{id}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, any]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + ctx := request.Context() + idPathParam := request.PathValue("id") + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Get(ctx, idPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /{id} Get(ctx, id)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Note() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "note") +} + +func (routePaths TemplateRoutePaths) Upload() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "upload") +} + +func (routePaths TemplateRoutePaths) Get(idPathParam string) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), routePaths.escapePathSegment(idPathParam)) +} + +func (routePaths TemplateRoutePaths) escapePathSegment(value string) string { + switch value { + case ".": + return "%2E" + case "..": + return "%2E%2E" + } + return url.PathEscape(value) +} diff --git a/internal/generate/testdata/generate/last_event_id.txtar b/internal/generate/testdata/generate/last_event_id.txtar new file mode 100644 index 00000000..ffe4c5f2 --- /dev/null +++ b/internal/generate/testdata/generate/last_event_id.txtar @@ -0,0 +1,186 @@ +lastEventID reads the Last-Event-Id header, parsed into the parameter +type. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /resume Resume(lastEventID)"}}{{.Result}}{{end}} +-- server.go -- +package server + +type T struct{} + +func (T) Resume(lastEventID uint64) string { return "" } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Resume(lastEventID uint64) string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /resume", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + lastEventIDParsed, err := strconv.ParseUint(request.Header.Get("Last-Event-Id"), 10, 64) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + lastEventID := lastEventIDParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Resume(lastEventID) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /resume Resume(lastEventID)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Resume() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "resume") +} diff --git a/internal/generate/testdata/generate/marshal_json.txtar b/internal/generate/testdata/generate/marshal_json.txtar new file mode 100644 index 00000000..b9a7ea9a --- /dev/null +++ b/internal/generate/testdata/generate/marshal_json.txtar @@ -0,0 +1,203 @@ +marshalJSON writes the result as JSON, rendering the template for its +side effects and on errors. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /api/{id} marshalJSON(Get(id))"}}{{.Err}}{{end}} +-- server.go -- +package server + +type Item struct{ ID int } + +type T struct{} + +func (T) Get(id int) (Item, error) { return Item{}, nil } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Get(id int) (Item, error) +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /api/{id}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, Item]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + idPathParam := idParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + var err error + td.result, err = receiver.Get(idPathParam) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusInternalServerError + } + } + if err := templates.ExecuteTemplate(buf, "GET /api/{id} marshalJSON(Get(id))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + if len(td.errList) == 0 { + buf.Reset() + jsonBody, err := json.Marshal(td.result) + if err != nil { + http.Error(response, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + _, _ = buf.Write(jsonBody) + response.Header().Set("content-type", "application/json") + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Get(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "api", strconv.Itoa(idPathParam)) +} diff --git a/internal/generate/testdata/generate/multipart.txtar b/internal/generate/testdata/generate/multipart.txtar new file mode 100644 index 00000000..2dc5b48f --- /dev/null +++ b/internal/generate/testdata/generate/multipart.txtar @@ -0,0 +1,241 @@ +A multipart struct binds text fields and file headers; a +*multipart.Form parameter receives the parsed form. + +Command line: muxt generate --use-receiver-type=T --output-multipart-max-memory=1MiB +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 1048576, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /upload Upload(multipart)"}}{{end}} +{{define "POST /raw Raw(multipart)"}}{{end}} +-- server.go -- +package server + +import "mime/multipart" + +type Upload struct { + Title string + File *multipart.FileHeader + Files []*multipart.FileHeader `name:"attachment"` +} + +type T struct{} + +func (T) Upload(in Upload) error { return nil } +func (T) Raw(form *multipart.Form) error { return nil } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "mime/multipart" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Raw(form *multipart.Form) error + Upload(in Upload) error +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("POST /raw", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, error]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + if err := request.ParseMultipartForm(1048576); err != nil && !errors.Is(err, http.ErrNotMultipart) { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + var multipart *multipart.Form = request.MultipartForm + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Raw(multipart) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /raw Raw(multipart)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("POST /upload", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, error]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + if err := request.ParseMultipartForm(1048576); err != nil && !errors.Is(err, http.ErrNotMultipart) { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + var multipart Upload + multipart.Title = request.FormValue("Title") + if request.MultipartForm != nil { + if fhs := request.MultipartForm.File["File"]; len(fhs) > 0 { + multipart.File = fhs[0] + } + } + if request.MultipartForm != nil { + multipart.Files = request.MultipartForm.File["attachment"] + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Upload(multipart) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /upload Upload(multipart)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Raw() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "raw") +} + +func (routePaths TemplateRoutePaths) Upload() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "upload") +} diff --git a/internal/generate/testdata/generate/nested_calls.txtar b/internal/generate/testdata/generate/nested_calls.txtar new file mode 100644 index 00000000..7ba69f84 --- /dev/null +++ b/internal/generate/testdata/generate/nested_calls.txtar @@ -0,0 +1,240 @@ +A call's argument may be the result of another call, a receiver method +or a package function, which runs first. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /article/{id} Show(ctx, Load(ctx, id))"}}{{.Result}}{{end}} +{{define "GET /user Show(ctx, Current(request))"}}{{.Result}}{{end}} +-- server.go -- +package server + +import ( + "context" + "net/http" +) + +type Article struct{ Title string } + +type T struct{} + +func (T) Load(ctx context.Context, id int) (Article, error) { return Article{}, nil } +func (T) Show(ctx context.Context, a Article) string { return "" } + +func Current(r *http.Request) (Article, bool) { return Article{}, false } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Load(ctx context.Context, id int) (Article, error) + Show(ctx context.Context, a Article) string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /article/{id}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + ctx := request.Context() + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + idPathParam := idParsed + result0, err := receiver.Load(ctx, idPathParam) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusInternalServerError + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Show(ctx, result0) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /article/{id} Show(ctx, Load(ctx, id))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /user", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + ctx := request.Context() + result0, ok := Current(request) + if !ok { + return + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Show(ctx, result0) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /user Show(ctx, Current(request))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) ReadArticleByIDCallingShow(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "article", strconv.Itoa(idPathParam)) +} + +func (routePaths TemplateRoutePaths) ReadUserCallingShow() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "user") +} diff --git a/internal/generate/testdata/generate/path_parameter_types.txtar b/internal/generate/testdata/generate/path_parameter_types.txtar new file mode 100644 index 00000000..755bada0 --- /dev/null +++ b/internal/generate/testdata/generate/path_parameter_types.txtar @@ -0,0 +1,443 @@ +Each path parameter parses into the type of the parameter it is passed +to, and the route path helper takes that type. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /int/{id} Int(id)"}}{{.Result}}{{end}} +{{define "GET /small/{a}/{b}/{c} Small(a, b, c)"}}{{.Result}}{{end}} +{{define "GET /bool/{flag} Bool(flag)"}}{{.Result}}{{end}} +{{define "GET /string/{name} String(name)"}}{{.Result}}{{end}} +{{define "GET /at/{at} At(at)"}}{{.Result}}{{end}} +{{define "GET /files/{path...} Files(path)"}}{{.Result}}{{end}} +{{define "GET /exact/{$} Exact()"}}{{.Result}}{{end}} +-- server.go -- +package server + +import "time" + +type T struct{} + +func (T) Int(id int) int { return id } +func (T) Small(a int8, b uint16, c int64) int64 { return c } +func (T) Bool(flag bool) bool { return flag } +func (T) String(name string) string { return name } +func (T) At(at time.Time) time.Time { return at } +func (T) Files(path string) string { return path } +func (T) Exact() string { return "" } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "sync" + "time" +) + +type RoutesReceiver interface { + At(at time.Time) time.Time + Bool(flag bool) bool + Exact() string + Files(path string) string + Int(id int) int + Small(a int8, b uint16, c int64) int64 + String(name string) string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /at/{at}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, time.Time]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + var atParsed time.Time + if err := atParsed.UnmarshalText([]byte(request.PathValue("at"))); err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + atPathParam := atParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.At(atPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /at/{at} At(at)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /bool/{flag}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, bool]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + flagParsed, err := strconv.ParseBool(request.PathValue("flag")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + flagPathParam := flagParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Bool(flagPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /bool/{flag} Bool(flag)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /exact/{$}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Exact() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /exact/{$} Exact()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /files/{path...}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + pathPathParam := request.PathValue("path") + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Files(pathPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /files/{path...} Files(path)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /int/{id}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, int]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + idPathParam := idParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Int(idPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /int/{id} Int(id)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /small/{a}/{b}/{c}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, int64]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + aParsed, err := strconv.ParseInt(request.PathValue("a"), 10, 8) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + aPathParam := int8(aParsed) + bParsed, err := strconv.ParseUint(request.PathValue("b"), 10, 16) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + bPathParam := uint16(bParsed) + cParsed, err := strconv.ParseInt(request.PathValue("c"), 10, 64) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + cPathParam := cParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Small(aPathParam, bPathParam, cPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /small/{a}/{b}/{c} Small(a, b, c)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /string/{name}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + namePathParam := request.PathValue("name") + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.String(namePathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /string/{name} String(name)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) At(atPathParam time.Time) (string, error) { + segment2_06e81700, err := atPathParam.MarshalText() + if err != nil { + return "", fmt.Errorf("failed to marshal path value {at} (segment 2) in /at/{at}: %w", err) + } + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "at", routePaths.escapePathSegment(string(segment2_06e81700))), nil +} + +func (routePaths TemplateRoutePaths) Bool(flagPathParam bool) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "bool", strconv.FormatBool(bool(flagPathParam))) +} + +func (routePaths TemplateRoutePaths) Exact() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "exact") + "/" +} + +func (routePaths TemplateRoutePaths) Files(pathPathParam string) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "files", routePaths.escapePathSegments(pathPathParam)) +} + +func (routePaths TemplateRoutePaths) Int(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "int", strconv.Itoa(idPathParam)) +} + +func (routePaths TemplateRoutePaths) Small(aPathParam int8, bPathParam uint16, cPathParam int64) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "small", strconv.FormatInt(int64(aPathParam), 10), strconv.FormatUint(uint64(bPathParam), 10), strconv.FormatInt(int64(cPathParam), 10)) +} + +func (routePaths TemplateRoutePaths) String(namePathParam string) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "string", routePaths.escapePathSegment(namePathParam)) +} + +func (routePaths TemplateRoutePaths) escapePathSegment(value string) string { + switch value { + case ".": + return "%2E" + case "..": + return "%2E%2E" + } + return url.PathEscape(value) +} + +func (routePaths TemplateRoutePaths) escapePathSegments(value string) string { + segments := strings.Split(value, "/") + for i, segment := range segments { + segments[i] = routePaths.escapePathSegment(segment) + } + return strings.Join(segments, "/") +} diff --git a/internal/generate/testdata/generate/receiver_method_sets.txtar b/internal/generate/testdata/generate/receiver_method_sets.txtar new file mode 100644 index 00000000..844e5aa8 --- /dev/null +++ b/internal/generate/testdata/generate/receiver_method_sets.txtar @@ -0,0 +1,250 @@ +Methods are found through embedded fields and pointer receivers; +functions in the package are called directly and never join the +receiver interface. + +Command line: muxt generate --use-receiver-type=Server +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "Server", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /embedded Embedded()"}}{{.Result}}{{end}} +{{define "GET /pointer Pointer()"}}{{.Result}}{{end}} +{{define "GET /function Function()"}}{{.Result}}{{end}} +-- server.go -- +package server + +type Base struct{} + +func (Base) Embedded() string { return "" } + +type Server struct{ Base } + +func (*Server) Pointer() int { return 0 } + +func Function() bool { return false } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Embedded() string + Pointer() int +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /embedded", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Embedded() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /embedded Embedded()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /function", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, bool]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = Function() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /function Function()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /pointer", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, int]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Pointer() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /pointer Pointer()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Embedded() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "embedded") +} + +func (routePaths TemplateRoutePaths) Function() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "function") +} + +func (routePaths TemplateRoutePaths) Pointer() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "pointer") +} diff --git a/internal/generate/testdata/generate/redirect.txtar b/internal/generate/testdata/generate/redirect.txtar new file mode 100644 index 00000000..d1bccde4 --- /dev/null +++ b/internal/generate/testdata/generate/redirect.txtar @@ -0,0 +1,250 @@ +Only a template that may call a redirect method gets the redirect block. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /redirects Save()"}}{{.RedirectSeeOther "/"}}{{end}} +{{define "POST /via-partial Save()"}}{{template "partial" .}}{{end}} +{{define "partial"}}{{.Redirect "/" 302}}{{end}} +{{define "GET /stays Save()"}}{{.Result}}{{end}} +-- server.go -- +package server + +type T struct{} + +func (T) Save() string { return "" } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Save() string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("POST /redirects", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Save() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /redirects Save()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if td.redirectURL != "" { + http.Redirect(response, request, td.redirectURL, statusCode) + return + } + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /stays", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Save() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /stays Save()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("POST /via-partial", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Save() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /via-partial Save()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if td.redirectURL != "" { + http.Redirect(response, request, td.redirectURL, statusCode) + return + } + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) CreateRedirectsCallingSave() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "redirects") +} + +func (routePaths TemplateRoutePaths) ReadStaysCallingSave() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "stays") +} + +func (routePaths TemplateRoutePaths) CreateViaPartialCallingSave() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "via-partial") +} diff --git a/internal/generate/testdata/generate/request_body.txtar b/internal/generate/testdata/generate/request_body.txtar new file mode 100644 index 00000000..3f1d02e5 --- /dev/null +++ b/internal/generate/testdata/generate/request_body.txtar @@ -0,0 +1,230 @@ +The body is passed as an io.Reader, or decoded as JSON into the +parameter's type. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /raw Raw(body)"}}{{end}} +{{define "POST /json Decode(ctx, unmarshalJSON(body))"}}{{end}} +-- server.go -- +package server + +import ( + "context" + "io" +) + +type Payload struct{ Name string } + +type T struct{} + +func (T) Raw(body io.Reader) error { return nil } +func (T) Decode(ctx context.Context, in Payload) error { return nil } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Decode(ctx context.Context, in Payload) error + Raw(body io.Reader) error +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("POST /json", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, error]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + ctx := request.Context() + var bodyValue Payload + if err := json.NewDecoder(request.Body).Decode(&bodyValue); err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Decode(ctx, bodyValue) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /json Decode(ctx, unmarshalJSON(body))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("POST /raw", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, error]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + body := request.Body + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Raw(body) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /raw Raw(body)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Decode() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "json") +} + +func (routePaths TemplateRoutePaths) Raw() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "raw") +} diff --git a/internal/generate/testdata/generate/response_argument.txtar b/internal/generate/testdata/generate/response_argument.txtar new file mode 100644 index 00000000..12fc8c22 --- /dev/null +++ b/internal/generate/testdata/generate/response_argument.txtar @@ -0,0 +1,172 @@ +A method taking the response writes it itself: muxt writes no status. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /download Download(response, request)"}}{{end}} +-- server.go -- +package server + +import "net/http" + +type T struct{} + +func (T) Download(w http.ResponseWriter, r *http.Request) string { return "" } +-- want/log.txt -- +warning: GET /download uses the response argument, so muxt does not manage this route's status codes, headers, or rendering; silence with MUXT_SILENCE_WARNING_HTTP_RESPONSE_ARGUMENT=true +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "sync" +) + +type RoutesReceiver interface { + Download(w http.ResponseWriter, r *http.Request) string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /download", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Download(response, request) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /download Download(response, request)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Download() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "download") +} diff --git a/internal/generate/testdata/generate/result_shapes.txtar b/internal/generate/testdata/generate/result_shapes.txtar new file mode 100644 index 00000000..665ba3a3 --- /dev/null +++ b/internal/generate/testdata/generate/result_shapes.txtar @@ -0,0 +1,286 @@ +A method returns a value, a value and an error, or a value and a bool. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /value Value()"}}{{.Result}}{{end}} +{{define "GET /error ValueError()"}}{{.Result}}{{end}} +{{define "GET /ok ValueOK()"}}{{.Result}}{{end}} +{{define "GET /function Function()"}}{{.Result}}{{end}} +-- server.go -- +package server + +type T struct{} + +func (T) Value() string { return "" } +func (T) ValueError() (int, error) { return 0, nil } +func (T) ValueOK() (bool, bool) { return false, false } + +func Function() string { return "" } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + ValueError() (int, error) + ValueOK() (bool, bool) + Value() string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /error", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, int]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + var err error + td.result, err = receiver.ValueError() + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusInternalServerError + } + } + if err := templates.ExecuteTemplate(buf, "GET /error ValueError()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /function", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = Function() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /function Function()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /ok", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, bool]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + var ok bool + td.result, ok = receiver.ValueOK() + if !ok { + return + } + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /ok ValueOK()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /value", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Value() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /value Value()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) ValueError() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "error") +} + +func (routePaths TemplateRoutePaths) Function() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "function") +} + +func (routePaths TemplateRoutePaths) ValueOk() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "ok") +} + +func (routePaths TemplateRoutePaths) Value() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "value") +} diff --git a/internal/generate/testdata/generate/route_without_call.txtar b/internal/generate/testdata/generate/route_without_call.txtar new file mode 100644 index 00000000..b24ba8b7 --- /dev/null +++ b/internal/generate/testdata/generate/route_without_call.txtar @@ -0,0 +1,195 @@ +A route with no call renders its template with no result. + +Command line: muxt generate +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{$}"}}
About
{{end}} +-- server.go -- +package server +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /about", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, struct { + }]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if err := templates.ExecuteTemplate(buf, "GET /about 203", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, http.StatusNonAuthoritativeInfo) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /{$}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, struct { + }]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if err := templates.ExecuteTemplate(buf, "GET /{$}", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) ReadAbout() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "about") +} + +func (routePaths TemplateRoutePaths) ReadExact() string { + return "/" +} diff --git a/internal/generate/testdata/generate/sse.txtar b/internal/generate/testdata/generate/sse.txtar new file mode 100644 index 00000000..270101c0 --- /dev/null +++ b/internal/generate/testdata/generate/sse.txtar @@ -0,0 +1,372 @@ +An sse route streams an event per callback, from the route template or +a same-named one, and reads lastEventID. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /events sse(Events(ctx, lastEventID, execute, sseClock))"}}{{.Result}}{{end}} +{{define "sseClock"}}{{.Result}}{{end}} +-- server.go -- +package server + +import "context" + +type T struct{} + +func (T) Events(ctx context.Context, lastEventID int, execute func(string) error, sseClock func(int) error) error { + return nil +} +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + "strconv" + "strings" + "sync" +) + +type RoutesReceiver interface { + Events(ctx context.Context, lastEventID int, execute func(string) error, sseClock func(int) error) error +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /events", func(response http.ResponseWriter, request *http.Request) { + defer func() { + _ = request.Body.Close() + }() + flusher, ok := response.(http.Flusher) + if !ok { + http.Error(response, "streaming unsupported", http.StatusInternalServerError) + return + } + ctx := request.Context() + lastEventIDParsed, err := strconv.Atoi(request.Header.Get("Last-Event-Id")) + if err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + lastEventID := lastEventIDParsed + h := response.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Connection", "keep-alive") + h.Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + flusher.Flush() + var mut sync.Mutex + if err := receiver.Events(ctx, lastEventID, func(result string) error { + if err := request.Context().Err(); err != nil { + return err + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + td := SSETemplateData[RoutesReceiver, string]{receiver: receiver, request: request, pathsPrefix: pathsPrefix, result: result} + if err := templates.ExecuteTemplate(buf, "GET /events sse(Events(ctx, lastEventID, execute, sseClock))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + return err + } + td.data = buf + mut.Lock() + defer mut.Unlock() + if _, err := td.WriteTo(response); err != nil { + return err + } + flusher.Flush() + return nil + }, func(result int) error { + if err := request.Context().Err(); err != nil { + return err + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + td := SSETemplateData[RoutesReceiver, int]{receiver: receiver, request: request, pathsPrefix: pathsPrefix, result: result} + if err := templates.ExecuteTemplate(buf, "sseClock", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + return err + } + td.data = buf + mut.Lock() + defer mut.Unlock() + if _, err := td.WriteTo(response); err != nil { + return err + } + flusher.Flush() + return nil + }); err != nil { + slog.ErrorContext(request.Context(), "sse handler returned an error", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + } + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type SSETemplateData[R, T any] struct { + receiver R + request *http.Request + result T + pathsPrefix string + event, id *string + retryMilliseconds *int + errList []error + data *bytes.Buffer +} + +func (m *SSETemplateData[R, T]) String() string { + return "" +} + +func (m *SSETemplateData[R, T]) Receiver() R { + return m.receiver +} + +func (m *SSETemplateData[R, T]) Request() *http.Request { + return m.request +} + +func (m *SSETemplateData[R, T]) Result() T { + return m.result +} + +func (m *SSETemplateData[R, T]) Err() error { + return errors.Join(m.errList...) +} + +func (m *SSETemplateData[R, T]) Event(event string) *SSETemplateData[R, T] { + m.event = &event + return m +} + +func (m *SSETemplateData[R, T]) ID(id string) *SSETemplateData[R, T] { + m.id = &id + return m +} + +func (m *SSETemplateData[R, T]) Retry(retryMilliseconds int) *SSETemplateData[R, T] { + m.retryMilliseconds = &retryMilliseconds + return m +} + +func (m *SSETemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: m.pathsPrefix} +} + +func (m *SSETemplateData[R, T]) WriteTo(w io.Writer) (int64, error) { + if m.id != nil && strings.ContainsAny(*m.id, "\r\n\x00") { + return 0, errors.New("sse: id contains a forbidden character") + } + if m.event != nil && strings.ContainsAny(*m.event, "\r\n") { + return 0, errors.New("sse: event contains a forbidden character") + } + var bytesWritten int + if m.id != nil { + if n, err := io.WriteString(w, "id: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.id); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.event != nil { + if n, err := io.WriteString(w, "event: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.event); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.retryMilliseconds != nil { + if n, err := io.WriteString(w, "retry: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + var retryBuf [20]byte + if n, err := w.Write(strconv.AppendInt(retryBuf[:0], int64(*m.retryMilliseconds), 10)); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + data := m.data.Bytes() + if bytes.IndexByte(data, '\r') >= 0 { + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + data = bytes.ReplaceAll(data, []byte("\r"), []byte("\n")) + } + data = bytes.TrimSuffix(data, []byte{'\n'}) + for line := range bytes.SplitSeq(data, []byte{'\n'}) { + if n, err := io.WriteString(w, "data: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if len(line) > 0 { + if n, err := w.Write(line); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + return int64(bytesWritten), nil +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Events() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "events") +} diff --git a/internal/generate/testdata/generate/sse_datastar.txtar b/internal/generate/testdata/generate/sse_datastar.txtar new file mode 100644 index 00000000..991f5a1c --- /dev/null +++ b/internal/generate/testdata/generate/sse_datastar.txtar @@ -0,0 +1,408 @@ +With --output-datastar an sse route frames datastar patch events, and +signals decodes the request body. + +Command line: muxt generate --use-receiver-type=T --output-datastar +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": true, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /count sse(Count(signals, execute, countsSignals))"}}{{.Result}}{{end}} +-- server.go -- +package server + +type Signals struct{ Count int } + +type T struct{} + +func (T) Count(in Signals, execute func(int) error, countsSignals func(Signals) error) {} +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + "strconv" + "strings" + "sync" +) + +type RoutesReceiver interface { + Count(in Signals, execute func(int) error, countsSignals func(Signals) error) +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("POST /count", func(response http.ResponseWriter, request *http.Request) { + defer func() { + _ = request.Body.Close() + }() + flusher, ok := response.(http.Flusher) + if !ok { + http.Error(response, "streaming unsupported", http.StatusInternalServerError) + return + } + var bodyValue Signals + if err := json.NewDecoder(request.Body).Decode(&bodyValue); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + h := response.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Connection", "keep-alive") + h.Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + flusher.Flush() + var mut sync.Mutex + receiver.Count(bodyValue, func(result int) error { + if err := request.Context().Err(); err != nil { + return err + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + td := SSETemplateData[RoutesReceiver, int]{receiver: receiver, request: request, pathsPrefix: pathsPrefix, result: result} + if err := templates.ExecuteTemplate(buf, "POST /count sse(Count(signals, execute, countsSignals))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + return err + } + td.data = buf + mut.Lock() + defer mut.Unlock() + if _, err := td.WriteTo(response); err != nil { + return err + } + flusher.Flush() + return nil + }, func(result Signals) error { + if err := request.Context().Err(); err != nil { + return err + } + payload, err := json.Marshal(result) + if err != nil { + return err + } + mut.Lock() + defer mut.Unlock() + if _, err := io.WriteString(response, "event: datastar-patch-signals\ndata: signals "); err != nil { + return err + } + if _, err := response.Write(payload); err != nil { + return err + } + if _, err := io.WriteString(response, "\n\n"); err != nil { + return err + } + flusher.Flush() + return nil + }) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type SSETemplateData[R, T any] struct { + receiver R + request *http.Request + result T + pathsPrefix string + event, id *string + retryMilliseconds *int + errList []error + data *bytes.Buffer + selector, mode *string + useViewTransition bool +} + +func (m *SSETemplateData[R, T]) String() string { + return "" +} + +func (m *SSETemplateData[R, T]) Receiver() R { + return m.receiver +} + +func (m *SSETemplateData[R, T]) Request() *http.Request { + return m.request +} + +func (m *SSETemplateData[R, T]) Result() T { + return m.result +} + +func (m *SSETemplateData[R, T]) Err() error { + return errors.Join(m.errList...) +} + +func (m *SSETemplateData[R, T]) ID(id string) *SSETemplateData[R, T] { + m.id = &id + return m +} + +func (m *SSETemplateData[R, T]) Retry(retryMilliseconds int) *SSETemplateData[R, T] { + m.retryMilliseconds = &retryMilliseconds + return m +} + +func (m *SSETemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: m.pathsPrefix} +} + +func (m *SSETemplateData[R, T]) Selector(selector string) *SSETemplateData[R, T] { + m.selector = &selector + return m +} + +func (m *SSETemplateData[R, T]) Mode(mode string) *SSETemplateData[R, T] { + m.mode = &mode + return m +} + +func (m *SSETemplateData[R, T]) UseViewTransition(value bool) *SSETemplateData[R, T] { + m.useViewTransition = value + return m +} + +func (m *SSETemplateData[R, T]) WriteTo(w io.Writer) (int64, error) { + if m.id != nil && strings.ContainsAny(*m.id, "\r\n\x00") { + return 0, errors.New("sse: id contains a forbidden character") + } + if m.selector != nil && strings.ContainsAny(*m.selector, "\r\n") { + return 0, errors.New("sse: selector contains a forbidden character") + } + if m.mode != nil && strings.ContainsAny(*m.mode, "\r\n") { + return 0, errors.New("sse: mode contains a forbidden character") + } + var bytesWritten int + if n, err := io.WriteString(w, "event: datastar-patch-elements\n"); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if m.id != nil { + if n, err := io.WriteString(w, "id: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.id); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.retryMilliseconds != nil { + if n, err := io.WriteString(w, "retry: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + var retryBuf [20]byte + if n, err := w.Write(strconv.AppendInt(retryBuf[:0], int64(*m.retryMilliseconds), 10)); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.selector != nil { + if n, err := io.WriteString(w, "data: selector "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.selector); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.mode != nil { + if n, err := io.WriteString(w, "data: mode "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.mode); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.useViewTransition { + if n, err := io.WriteString(w, "data: useViewTransition true\n"); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + data := m.data.Bytes() + if bytes.IndexByte(data, '\r') >= 0 { + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + data = bytes.ReplaceAll(data, []byte("\r"), []byte("\n")) + } + data = bytes.TrimSuffix(data, []byte{'\n'}) + for line := range bytes.SplitSeq(data, []byte{'\n'}) { + if n, err := io.WriteString(w, "data: elements "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write(line); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + return int64(bytesWritten), nil +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Count() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "count") +} diff --git a/internal/generate/testdata/generate/sse_messages_without_datastar.txtar b/internal/generate/testdata/generate/sse_messages_without_datastar.txtar new file mode 100644 index 00000000..cc0e6a1b --- /dev/null +++ b/internal/generate/testdata/generate/sse_messages_without_datastar.txtar @@ -0,0 +1,344 @@ +An sse method returning nothing, with a data-less callback. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /ticks/{id} sse(Ticks(request, id, execute))"}}tick{{end}} +-- server.go -- +package server + +import "net/http" + +type T struct{} + +func (T) Ticks(r *http.Request, id int, execute func() error) {} +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + "strconv" + "strings" + "sync" +) + +type RoutesReceiver interface { + Ticks(r *http.Request, id int, execute func() error) +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /ticks/{id}", func(response http.ResponseWriter, request *http.Request) { + defer func() { + _ = request.Body.Close() + }() + flusher, ok := response.(http.Flusher) + if !ok { + http.Error(response, "streaming unsupported", http.StatusInternalServerError) + return + } + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + idPathParam := idParsed + h := response.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Connection", "keep-alive") + h.Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + flusher.Flush() + var mut sync.Mutex + receiver.Ticks(request, idPathParam, func() error { + if err := request.Context().Err(); err != nil { + return err + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + td := SSETemplateData[RoutesReceiver, struct{}]{receiver: receiver, request: request, pathsPrefix: pathsPrefix} + if err := templates.ExecuteTemplate(buf, "GET /ticks/{id} sse(Ticks(request, id, execute))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + return err + } + td.data = buf + mut.Lock() + defer mut.Unlock() + if _, err := td.WriteTo(response); err != nil { + return err + } + flusher.Flush() + return nil + }) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type SSETemplateData[R, T any] struct { + receiver R + request *http.Request + result T + pathsPrefix string + event, id *string + retryMilliseconds *int + errList []error + data *bytes.Buffer +} + +func (m *SSETemplateData[R, T]) String() string { + return "" +} + +func (m *SSETemplateData[R, T]) Receiver() R { + return m.receiver +} + +func (m *SSETemplateData[R, T]) Request() *http.Request { + return m.request +} + +func (m *SSETemplateData[R, T]) Result() T { + return m.result +} + +func (m *SSETemplateData[R, T]) Err() error { + return errors.Join(m.errList...) +} + +func (m *SSETemplateData[R, T]) Event(event string) *SSETemplateData[R, T] { + m.event = &event + return m +} + +func (m *SSETemplateData[R, T]) ID(id string) *SSETemplateData[R, T] { + m.id = &id + return m +} + +func (m *SSETemplateData[R, T]) Retry(retryMilliseconds int) *SSETemplateData[R, T] { + m.retryMilliseconds = &retryMilliseconds + return m +} + +func (m *SSETemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: m.pathsPrefix} +} + +func (m *SSETemplateData[R, T]) WriteTo(w io.Writer) (int64, error) { + if m.id != nil && strings.ContainsAny(*m.id, "\r\n\x00") { + return 0, errors.New("sse: id contains a forbidden character") + } + if m.event != nil && strings.ContainsAny(*m.event, "\r\n") { + return 0, errors.New("sse: event contains a forbidden character") + } + var bytesWritten int + if m.id != nil { + if n, err := io.WriteString(w, "id: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.id); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.event != nil { + if n, err := io.WriteString(w, "event: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.event); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.retryMilliseconds != nil { + if n, err := io.WriteString(w, "retry: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + var retryBuf [20]byte + if n, err := w.Write(strconv.AppendInt(retryBuf[:0], int64(*m.retryMilliseconds), 10)); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + data := m.data.Bytes() + if bytes.IndexByte(data, '\r') >= 0 { + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + data = bytes.ReplaceAll(data, []byte("\r"), []byte("\n")) + } + data = bytes.TrimSuffix(data, []byte{'\n'}) + for line := range bytes.SplitSeq(data, []byte{'\n'}) { + if n, err := io.WriteString(w, "data: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if len(line) > 0 { + if n, err := w.Write(line); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + return int64(bytesWritten), nil +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Ticks(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "ticks", strconv.Itoa(idPathParam)) +} diff --git a/internal/generate/testdata/generate/status_codes.txtar b/internal/generate/testdata/generate/status_codes.txtar new file mode 100644 index 00000000..1b62088e --- /dev/null +++ b/internal/generate/testdata/generate/status_codes.txtar @@ -0,0 +1,277 @@ +The status a route writes: one named in the template name, one the +result reports with a StatusCode method or field, or 200. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /created 201 Create()"}}{{end}} +{{define "GET /constant http.StatusTeapot Create()"}}{{end}} +{{define "GET /method Method()"}}{{end}} +{{define "GET /field Field()"}}{{end}} +-- server.go -- +package server + +type T struct{} + +func (T) Create() string { return "" } + +type Coded struct{ code int } + +func (c Coded) StatusCode() int { return c.code } + +func (T) Method() Coded { return Coded{} } + +type WithField struct{ StatusCode int } + +func (T) Field() WithField { return WithField{} } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Create() string + Field() WithField + Method() Coded +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /constant", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Create() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /constant http.StatusTeapot Create()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, http.StatusTeapot) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("POST /created", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Create() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /created 201 Create()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, http.StatusCreated) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /field", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, WithField]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Field() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /field Field()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, td.result.StatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /method", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, Coded]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Method() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /method Method()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, td.result.StatusCode(), defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) ReadConstantCallingCreate() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "constant") +} + +func (routePaths TemplateRoutePaths) CreateCreatedCallingCreate() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "created") +} + +func (routePaths TemplateRoutePaths) Field() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "field") +} + +func (routePaths TemplateRoutePaths) Method() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "method") +} diff --git a/internal/generate/testdata/generate/synthesized_method_note.txtar b/internal/generate/testdata/generate/synthesized_method_note.txtar new file mode 100644 index 00000000..87573fe3 --- /dev/null +++ b/internal/generate/testdata/generate/synthesized_method_note.txtar @@ -0,0 +1,229 @@ +With --use-receiver-type, a method the receiver does not define is +inferred, and generation says so. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{id} Missing(ctx, id)"}}{{.Result}}{{end}} +{{define "GET /defined Defined()"}}{{.Result}}{{end}} +-- server.go -- +package server + +type T struct{} + +func (T) Defined() string { return "" } +-- want/log.txt -- +note: T does not define Missing(ctx context.Context, id string) any +note: the inferred signatures return any — implement the methods to type-check the templates against real types +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Defined() string + Missing(ctx context.Context, id string) any +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /defined", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Defined() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /defined Defined()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /{id}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, any]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + ctx := request.Context() + idPathParam := request.PathValue("id") + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Missing(ctx, idPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /{id} Missing(ctx, id)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Defined() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "defined") +} + +func (routePaths TemplateRoutePaths) Missing(idPathParam string) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), routePaths.escapePathSegment(idPathParam)) +} + +func (routePaths TemplateRoutePaths) escapePathSegment(value string) string { + switch value { + case ".": + return "%2E" + case "..": + return "%2E%2E" + } + return url.PathEscape(value) +} diff --git a/internal/load/hydrate_test.go b/internal/load/hydrate_test.go new file mode 100644 index 00000000..4040d758 --- /dev/null +++ b/internal/load/hydrate_test.go @@ -0,0 +1,80 @@ +package load_test + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/typelate/muxt/internal/analysis" + "github.com/typelate/muxt/internal/generate" + "github.com/typelate/muxt/internal/load" + "github.com/typelate/muxt/internal/load/loadtest" +) + +const hydrateServer = `package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +type Server struct{} + +func Render(w io.Writer) error { + return templates.ExecuteTemplate(w, "page", nil) +} +` + +// TestHydration states what a command's configuration reads from a load, +// and which failure a configuration naming several missing things reports. +func TestHydration(t *testing.T) { + dir := t.TempDir() + pl := loadtest.Package(t, dir, "example.com/server", map[string]string{ + "server.go": hydrateServer, + "page.gohtml": `{{define "page"}}{{.}}
{{end}}`, + }) + + t.Run("the package, its receiver, and each variable", func(t *testing.T) { + pkg, receiver, err := load.RoutesSource(dir, pl, analysis.DefinitionsConfiguration{ReceiverType: "Server", TemplatesVariables: []string{"templates"}}) + require.NoError(t, err) + assert.Equal(t, "Server", receiver.Obj().Name()) + require.Len(t, pkg.Variables, 1) + variable := pkg.Variables[0] + assert.Equal(t, "templates", variable.Name) + require.Len(t, variable.Calls, 1) + assert.Equal(t, "page", variable.Calls[0].Template) + assert.Equal(t, filepath.Join(dir, "server.go"), variable.Calls[0].Position.Filename) + _, ok := variable.NamePosition("page") + assert.True(t, ok, "the page's definition is located") + }) + + t.Run("no receiver named is none looked up", func(t *testing.T) { + _, receiver, err := load.RoutesSource(dir, pl, analysis.DefinitionsConfiguration{TemplatesVariables: []string{"templates"}}) + require.NoError(t, err) + assert.Nil(t, receiver) + }) + + t.Run("a missing receiver is reported before a missing variable", func(t *testing.T) { + _, _, err := load.RoutesSource(dir, pl, analysis.DefinitionsConfiguration{ReceiverType: "Srever", TemplatesVariables: []string{"nope"}}) + require.EqualError(t, err, "could not find receiver type Srever in example.com/server; did you mean Server?") + }) + + t.Run("the first variable that does not evaluate", func(t *testing.T) { + _, err := load.Package(dir, pl, []string{"templates", "nope", "neither"}) + require.EqualError(t, err, "variable nope not found in package example.com/server") + }) + + t.Run("the routes file belongs to the package in its own directory", func(t *testing.T) { + _, _, err := load.GenerateSource(dir, pl, generate.RoutesFileConfiguration{OutputFileName: filepath.Join("sub", "routes.go"), ReceiverType: "Srever"}) + require.Error(t, err) + assert.Equal(t, "no Go package found at "+filepath.Join(dir, "sub"), err.Error(), "a missing package is reported before a missing receiver") + }) +} diff --git a/internal/load/loadtest/loadtest.go b/internal/load/loadtest/loadtest.go new file mode 100644 index 00000000..64785057 --- /dev/null +++ b/internal/load/loadtest/loadtest.go @@ -0,0 +1,228 @@ +// Package loadtest builds the result of a package load, type checked against +// the official standard library, for tests of what internal/load and its +// callers do with a loaded package. +// +// packages.Load runs go list over the whole package graph, which takes +// seconds. What muxt reads from its result -- syntax, type information, the +// embedded files -- is plain data. Package type checks the files it is given +// in memory and imports the standard library from the go command's export +// data, which the build cache keeps: listing it costs a test binary a couple +// of hundred milliseconds once, and reading a package is shared after that. The +// standard library is whichever the go command in use provides, so the +// tests follow the toolchain rather than a copy of its API. +// +// Package writes the files to disk too, because evaluating ParseFS and +// reading template sources both read them from there. +package loadtest + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "go/types" + "os" + "os/exec" + "path" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + + "golang.org/x/tools/go/gcexportdata" + "golang.org/x/tools/go/packages" +) + +// FileSet positions every package Package loads, and the standard library +// packages imported for them. +var FileSet = token.NewFileSet() + +// The standard library is read from the export data the go command writes +// for it, with gcexportdata -- the reader go/packages uses -- into one map +// shared by every package this test binary loads, so a type a test names is +// the same object wherever it is reached. +// +// Export data lists only the packages a package's API mentions, while a +// load reports every package it imports; check finds fmt, whose functions a +// template calls, through html/template's imports. So each package's +// imports are set to the ones go list reports, as go/packages sets them. +var ( + importLock sync.Mutex + stdlib = make(map[string]*types.Package) + imported = make(map[string]bool) + + stdList = sync.OnceValues(func() (map[string]stdPackage, error) { + // Tab separated: an export path holds whatever the build cache's + // directory is called, spaces included. + out, err := exec.Command("go", "list", "-export", "-f", "{{.ImportPath}}\t{{.Export}}\t{{join .Imports \",\"}}", "std").Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("go list -export std: %w: %s", err, exitErr.Stderr) + } + return nil, fmt.Errorf("go list -export std: %w", err) + } + list := make(map[string]stdPackage) + for line := range strings.Lines(string(out)) { + fields := strings.Split(strings.TrimRight(line, "\n"), "\t") + // A package the go command builds nothing for -- the + // standard library's own test packages -- has no export + // data to read, so it is not one to import. + if len(fields) < 2 || fields[1] == "" { + continue + } + pkg := stdPackage{export: fields[1]} + if len(fields) > 2 && fields[2] != "" { + pkg.imports = strings.Split(fields[2], ",") + } + list[fields[0]] = pkg + } + return list, nil + }) +) + +type stdPackage struct { + export string + imports []string +} + +func importStd(path string) (*types.Package, error) { + importLock.Lock() + defer importLock.Unlock() + return importStdLocked(path) +} + +func importStdLocked(path string) (*types.Package, error) { + if path == "unsafe" { + return types.Unsafe, nil + } + if pkg, ok := stdlib[path]; ok && imported[path] { + return pkg, nil + } + list, err := stdList() + if err != nil { + return nil, err + } + entry, ok := list[path] + if !ok { + return nil, fmt.Errorf("loadtest imports only the standard library, and %q is not in it", path) + } + f, err := os.Open(entry.export) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + r, err := gcexportdata.NewReader(f) + if err != nil { + return nil, fmt.Errorf("reading export data for %s: %w", path, err) + } + pkg, err := gcexportdata.Read(r, FileSet, stdlib, path) + if err != nil { + return nil, fmt.Errorf("reading export data for %s: %w", path, err) + } + imported[path] = true + imports := make([]*types.Package, 0, len(entry.imports)) + for _, importPath := range entry.imports { + if _, listed := list[importPath]; !listed && importPath != "unsafe" { + continue + } + dep, err := importStdLocked(importPath) + if err != nil { + return nil, err + } + imports = append(imports, dep) + } + pkg.SetImports(imports) + return pkg, nil +} + +type importerFunc func(path string) (*types.Package, error) + +func (fn importerFunc) Import(path string) (*types.Package, error) { return fn(path) } + +// Package writes files into dir and returns them loaded as the package with +// import path pkgPath, the way load.Packages would report a load of dir: that +// package, then the standard library packages every load includes. +// +// Go files are type checked against the standard library. Every other file +// is embedded, as a //go:embed pattern covering it would. Keys are +// slash-separated paths relative to dir; files in subdirectories are written +// but not part of the package. +func Package(t testing.TB, dir, pkgPath string, files map[string]string) []*packages.Package { + t.Helper() + var goPaths, embedded []string + for name, content := range files { + file := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(file), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + switch { + case path.Dir(name) != ".": + case strings.HasSuffix(name, ".go"): + goPaths = append(goPaths, file) + default: + embedded = append(embedded, file) + } + } + slices.Sort(goPaths) + slices.Sort(embedded) + + // load.Packages always loads these alongside the working directory's + // package, so what a route argument binds to is found even when the + // package does not import it. They are imported before the package is + // checked: a package the checker reaches only through another's imports + // is read shallowly, and it is these complete ones -- fmt, whose + // functions a template calls -- that the load's other packages share. + roots := []string{"encoding", "fmt", "net/http"} + std := make([]*types.Package, 0, len(roots)) + for _, stdPath := range roots { + pkg, err := importStd(stdPath) + if err != nil { + t.Fatal(err) + } + std = append(std, pkg) + } + + syntax := make([]*ast.File, 0, len(goPaths)) + for _, file := range goPaths { + parsed, err := parser.ParseFile(FileSet, file, files[filepath.Base(file)], parser.ParseComments|parser.SkipObjectResolution) + if err != nil { + t.Fatal(err) + } + syntax = append(syntax, parsed) + } + info := &types.Info{ + Types: make(map[ast.Expr]types.TypeAndValue), + Instances: make(map[*ast.Ident]types.Instance), + Defs: make(map[*ast.Ident]types.Object), + Uses: make(map[*ast.Ident]types.Object), + Implicits: make(map[ast.Node]types.Object), + Selections: make(map[*ast.SelectorExpr]*types.Selection), + Scopes: make(map[ast.Node]*types.Scope), + } + config := types.Config{Importer: importerFunc(importStd)} + pkg, err := config.Check(pkgPath, FileSet, syntax, info) + if err != nil { + t.Fatal(err) + } + + pl := []*packages.Package{{ + ID: pkgPath, + Name: pkg.Name(), + PkgPath: pkgPath, + Dir: dir, + GoFiles: goPaths, + EmbedFiles: embedded, + Fset: FileSet, + Syntax: syntax, + Types: pkg, + TypesInfo: info, + }} + for _, pkg := range std { + pl = append(pl, &packages.Package{ID: pkg.Path(), Name: pkg.Name(), PkgPath: pkg.Path(), Fset: FileSet, Types: pkg}) + } + return pl +} diff --git a/internal/load/loadtest/loadtest_test.go b/internal/load/loadtest/loadtest_test.go new file mode 100644 index 00000000..166119b3 --- /dev/null +++ b/internal/load/loadtest/loadtest_test.go @@ -0,0 +1,64 @@ +package loadtest_test + +import ( + "path/filepath" + "slices" + "testing" + + "github.com/typelate/muxt/internal/load" + "github.com/typelate/muxt/internal/load/loadtest" +) + +func TestPackageLoadsTemplates(t *testing.T) { + dir := t.TempDir() + pl := loadtest.Package(t, dir, "example.com/server", map[string]string{ + "server.go": `package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +var inline = template.Must(template.New("inline").Delims("[[", "]]").Parse(` + "`[[define \"note\"]][[.]][[end]]`" + `)) + +func Render(w io.Writer, name string) error { + return templates.ExecuteTemplate(w, "page", name) +} +`, + "page.gohtml": `{{define "page"}}{{.}}
{{end}}`, + }) + + pkg, err := load.Package(dir, pl, []string{"templates", "inline"}) + if err != nil { + t.Fatal(err) + } + if pkg.Types.Path() != "example.com/server" { + t.Errorf("package %s, want example.com/server", pkg.Types.Path()) + } + sets := pkg.Variables + + if sets[0].Set.Lookup("page") == nil { + t.Error("templates does not hold the page ParseFS read") + } + definition, ok := sets[0].Definitions["page"] + if !ok || definition.Define.Filename != filepath.Join(dir, "page.gohtml") { + t.Errorf("page defined at %+v, want in page.gohtml", definition.Define) + } + if got := len(sets[0].Calls); got != 1 { + t.Errorf("%d ExecuteTemplate calls, want the one Render makes", got) + } + var names []string + for _, tmpl := range sets[1].Set.Templates() { + names = append(names, tmpl.Name()) + } + slices.Sort(names) + if !slices.Equal(names, []string{"inline", "note"}) { + t.Errorf("inline holds %q, want inline and note", names) + } +} diff --git a/internal/load/stdlib_test.go b/internal/load/stdlib_test.go new file mode 100644 index 00000000..3d29eb85 --- /dev/null +++ b/internal/load/stdlib_test.go @@ -0,0 +1,86 @@ +package load_test + +import ( + "go/types" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/typelate/muxt/internal/load" + "github.com/typelate/muxt/internal/load/loadtest" + "github.com/typelate/muxt/internal/muxt" +) + +// TestStandardLibrary states what the one checker backed by the official +// standard library answers, which the mock checkers in other packages' tests +// stand in for. +func TestStandardLibrary(t *testing.T) { + dir := t.TempDir() + pl := loadtest.Package(t, dir, "example.com/server", map[string]string{ + "server.go": `package server + +import ( + "html/template" + "time" +) + +type Plain struct{} + +var ( + _ time.Time + _ template.HTML +) +`, + }) + std := load.StandardLibrary(pl) + + t.Run("the reserved identifiers", func(t *testing.T) { + for identifier, want := range map[string]string{ + muxt.TemplateNameScopeIdentifierHTTPRequest: "*net/http.Request", + muxt.TemplateNameScopeIdentifierHTTPResponse: "net/http.ResponseWriter", + muxt.TemplateNameScopeIdentifierContext: "context.Context", + muxt.TemplateNameScopeIdentifierForm: "net/url.Values", + muxt.TemplateNameScopeIdentifierMultipart: "*mime/multipart.Form", + muxt.TemplateNameScopeIdentifierRequestBody: "io.Reader", + } { + tp, err := std.ScopeType(identifier) + require.NoError(t, err, identifier) + assert.Equal(t, want, types.TypeString(tp, nil), identifier) + } + _, err := std.ScopeType("lastEventID") + assert.EqualError(t, err, "lastEventID is not a reserved argument identifier") + }) + + t.Run("the types a binding needs", func(t *testing.T) { + fileHeader, err := std.FileHeader() + require.NoError(t, err) + assert.Equal(t, "*mime/multipart.FileHeader", types.TypeString(fileHeader, nil)) + rawJSON, err := std.RawJSON() + require.NoError(t, err) + assert.Equal(t, "encoding/json.RawMessage", types.TypeString(rawJSON, nil)) + }) + + t.Run("text marshaling", func(t *testing.T) { + var timeType types.Type + for _, imported := range pl[0].Types.Imports() { + if imported.Path() == "time" { + timeType = imported.Scope().Lookup("Time").Type() + } + } + require.NotNil(t, timeType) + plain := pl[0].Types.Scope().Lookup("Plain").Type() + assert.True(t, std.TextUnmarshaler(timeType), "a *time.Time parses from text") + assert.True(t, std.TextMarshaler(timeType), "a time.Time formats as text") + assert.False(t, std.TextUnmarshaler(plain)) + assert.False(t, std.TextMarshaler(plain)) + }) + + t.Run("a package the load did not reach", func(t *testing.T) { + // Only the package itself, which imports nothing: encoding/json is + // reached through html/template's imports in a real package. + bare := t.TempDir() + _, err := load.StandardLibrary(loadtest.Package(t, bare, "example.com/bare", map[string]string{"bare.go": "package bare\n"})[:1]).RawJSON() + assert.EqualError(t, err, `could not find package "encoding/json" for RawMessage`) + }) +} From cb52fbe4219e5a18fef556fb0b37849bfd0beaab Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:05:05 -0700 Subject: [PATCH 2/5] Snapshot the template checks and listings, from packages loaded in memory muxt check is the command the integration suite runs most, and its reports -- a field the data type lacks, a template nothing renders, a route waiting for muxt generate -- were only stated there, behind a package load per script. internal/analysis/testdata/never rendered
{{end}} +{{define "GET /items Items()"}}{{template "row" .}}{{end}} +{{define "row"}}[[.]]
[[end]]`)) + +func RenderNote(w io.Writer, note string) error { + return templates.ExecuteTemplate(w, "note", note) +} +-- want/report.txt -- +1 mutant across 1 template (complexity 1, seed 1) +dry run: no tests were run + +server.go:11:9 ExecuteTemplate "note" (dot: string) + "note" server.go (complexity 1, dot: string) + PEND 8:98 action-zero + +1 mutant, 1 runnable, 0 skipped diff --git a/internal/mutation/testdata/test-template-mutations/diff.txtar b/internal/mutation/testdata/test-template-mutations/diff.txtar new file mode 100644 index 00000000..93bca4f4 --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/diff.txtar @@ -0,0 +1,85 @@ +With --diff, a template whose text and dot are unchanged since the +revision is left alone and reported; one that reads differently is +mutated. + +Command line: muxt test-template-mutations --dry-run --seed=1 -v --diff=main +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": true, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 8, + "Workers": 1, + "Diff": "main", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +type Page struct{ Title, Body string } + +func RenderPage(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} +-- page.gohtml -- +{{define "page"}}{{template "header" .}}{{template "body" .}}{{end}} +{{define "header"}}static
{{end}} +-- want/error.txt -- +no mutations available: the 1 template(s) reached hold no dynamic or control flow actions, so a run would report every mutant killed without testing anything diff --git a/internal/mutation/testdata/test-template-mutations/literal_template.txtar b/internal/mutation/testdata/test-template-mutations/literal_template.txtar new file mode 100644 index 00000000..b0a12a8f --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/literal_template.txtar @@ -0,0 +1,51 @@ +A template written as a Go string literal: its actions and branches are +mutated where they are written in the literal. + +Command line: muxt test-template-mutations --dry-run --seed=1 -v +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": true, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 8, + "Workers": 1, + "Diff": "", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "html/template" + "io" +) + +var templates = template.Must(template.New("greeting").Parse(`Hello, {{.Name}}!{{if .Loud}} !!!{{end}}`)) + +type Greeting struct { + Name string + Loud bool +} + +func Render(w io.Writer, greeting Greeting) error { + return templates.ExecuteTemplate(w, "greeting", greeting) +} +-- want/report.txt -- +3 mutants across 1 template (complexity 2, seed 1) +dry run: no tests were run + +server.go:16:9 ExecuteTemplate "greeting" (dot: server.Greeting) + "greeting" server.go (complexity 2, dot: server.Greeting) + PEND 8:70 action-zero + PEND 8:80 if-false + PEND 8:80 if-true + +3 mutants, 3 runnable, 0 skipped diff --git a/internal/mutation/testdata/test-template-mutations/operand_budget.txtar b/internal/mutation/testdata/test-template-mutations/operand_budget.txtar new file mode 100644 index 00000000..8db628e5 --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/operand_budget.txtar @@ -0,0 +1,53 @@ +An action with more operand combinations than --max-cases allows +contributes none, and says so where its mutants would have been. + +Command line: muxt test-template-mutations --dry-run --seed=1 -v --max-cases=2 +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": true, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 2, + "Workers": 1, + "Diff": "", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +type Page struct{ A, B, C int } + +func RenderPage(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} +-- page.gohtml -- +{{define "page"}}{{printf "%d %d %d" .A .B .C}}{{end}} +-- want/report.txt -- +2 mutants across 1 template (complexity 1, seed 1) +dry run: no tests were run + +server.go:17:9 ExecuteTemplate "page" (dot: server.Page) + "page" page.gohtml (complexity 1, dot: server.Page) + PEND 1:18 action-zero + SKIP 1:18 operands (3 operands need 7 cases, over --max-cases=2) + +2 mutants, 1 runnable, 1 skipped diff --git a/internal/mutation/testdata/test-template-mutations/partials_and_trims.txtar b/internal/mutation/testdata/test-template-mutations/partials_and_trims.txtar new file mode 100644 index 00000000..b2a4bc9f --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/partials_and_trims.txtar @@ -0,0 +1,77 @@ +A page renders partials through {{template}}, narrowing dot through a +range. A partial reached twice with the same dot is mutated once, and the +repeat is reported as trimmed. + +Command line: muxt test-template-mutations --dry-run --seed=1 -v +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": true, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 8, + "Workers": 1, + "Diff": "", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +type Page struct { + Title string + Items []Item +} + +type Item struct { + Name string + Price int +} + +func RenderPage(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} + +func RenderAgain(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} +-- page.gohtml -- +{{define "page"}}{{.A}} & {{.B}}
"} + + var want strings.Builder + want.WriteString("{\n\t\"Names\": null,\n\t\"Counts\": null,\n\t\"Imports\": {\n") + for i := range 16 { + separator := "," + if i == 15 { + separator = "" + } + fmt.Fprintf(&want, "\t\t\"example.com/pkg%02d\": \"pkg%02d\"%s\n", i, i, separator) + } + want.WriteString("\t},\n\t\"Source\": \"\\u003cp\\u003e{{.A}} \\u0026 {{.B}}\\u003c/p\\u003e\\u2028\"\n}\n") + + cmd := &cobra.Command{} + cmd.Flags().String("format", "json", "") + // Map iteration order changes run to run, so one lucky ordering must not + // pass. + for range 20 { + var got bytes.Buffer + require.NoError(t, writeResult(cmd, &got, result)) + assert.Equal(t, want.String(), got.String()) + } +} diff --git a/internal/mutation/report.go b/internal/mutation/report.go index f700342f..8ba304ec 100644 --- a/internal/mutation/report.go +++ b/internal/mutation/report.go @@ -44,7 +44,7 @@ type Result struct { Reason string `json:"reason,omitempty"` // Seconds is how long the mutant's test run took. - Seconds float64 `json:"seconds,omitempty"` + Seconds float64 `json:"seconds,omitzero"` // mutantIndex locates the mutant this result came from, so the run // does not have to carry the mutants inside the report it prints. @@ -107,7 +107,7 @@ type BaselineResult struct { // Seconds is how long the unmutated run took, which is what the // estimate for the whole run is built from. - Seconds float64 `json:"seconds,omitempty"` + Seconds float64 `json:"seconds,omitzero"` } // Report is the outcome of a whole mutation run. diff --git a/internal/mutation/runner.go b/internal/mutation/runner.go index 5175949f..405a4e34 100644 --- a/internal/mutation/runner.go +++ b/internal/mutation/runner.go @@ -1,7 +1,7 @@ package mutation import ( - "encoding/json" + "encoding/json/v2" "fmt" "io" "os" diff --git a/internal/mutation/runner_test.go b/internal/mutation/runner_test.go index fe84e741..dc4ccb10 100644 --- a/internal/mutation/runner_test.go +++ b/internal/mutation/runner_test.go @@ -1,7 +1,7 @@ package mutation import ( - "encoding/json" + "encoding/json/v2" "errors" "fmt" "os" From 41fd53a0d0f48ad8ac6c6a30f1e99033b5e372a1 Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:41:06 -0700 Subject: [PATCH 5/5] Say it with slices and maps A few loops spelled out what the slices and maps packages already name: gathering a map's keys and sorting them, asking whether a list holds something, copying a list, and a union of keys de-duplicated by hand. - slices.Sorted(maps.Keys(m)) where keys were gathered and then sorted: the module listing's directories, muxttest.Check's file names, and the template source files generation walks. sort is no longer imported anywhere in muxt's own code. - slices.ContainsFunc where a loop or an IndexFunc whose index went unused only asked whether something is there: marshalJSON refusing the execute callback, and a receiver method already in the interface. - slices.Clone for File.ImportSpecs' copy; its callers only range over the result or take its length. - sortedKeys in the generate snapshot harness appends every map's keys, sorts and compacts, rather than checking Contains before each append. Loops that find an element to change in place, build a different type, or return early with more than a yes or no stay as they were. Every generated file and report is unchanged. Assisted-by: Claude:claude-opus-5 gofumpt --- internal/analysis/module.go | 11 +++-------- internal/generate/file.go | 2 +- internal/generate/marshal_json.go | 9 +++++---- internal/generate/routes.go | 7 +++---- internal/generate/snapshot_test.go | 13 +++++-------- internal/muxt/muxttest/muxttest.go | 8 ++------ 6 files changed, 19 insertions(+), 31 deletions(-) diff --git a/internal/analysis/module.go b/internal/analysis/module.go index d1f56e1f..d4615a92 100644 --- a/internal/analysis/module.go +++ b/internal/analysis/module.go @@ -5,11 +5,12 @@ import ( "bytes" "encoding/json/v2" "io" + "maps" "os" "os/exec" "path/filepath" "regexp" - "sort" + "slices" "strings" "github.com/spf13/pflag" @@ -155,14 +156,8 @@ func NewModule(workingDirectory string, addFlags func(*pflag.FlagSet, *generate. return nil, err } - dirs := make([]string, 0, len(dirMap)) - for dir := range dirMap { - dirs = append(dirs, dir) - } - sort.Strings(dirs) - var packages []PackageInfo - for _, dir := range dirs { + for _, dir := range slices.Sorted(maps.Keys(dirMap)) { entry := dirMap[dir] var config generate.RoutesFileConfiguration set := pflag.NewFlagSet("parse-header", pflag.ContinueOnError) diff --git a/internal/generate/file.go b/internal/generate/file.go index 134dff69..855bcc27 100644 --- a/internal/generate/file.go +++ b/internal/generate/file.go @@ -62,7 +62,7 @@ func (file *File) Import(pkgIdent, pkgPath string) string { } func (file *File) ImportSpecs() []*ast.ImportSpec { - result := append(make([]*ast.ImportSpec, 0, len(file.importSpecs)), file.importSpecs...) + result := slices.Clone(file.importSpecs) slices.SortFunc(result, func(a, b *ast.ImportSpec) int { return strings.Compare(a.Path.Value, b.Path.Value) }) return slices.CompactFunc(result, func(a, b *ast.ImportSpec) bool { return a.Path.Value == b.Path.Value }) } diff --git a/internal/generate/marshal_json.go b/internal/generate/marshal_json.go index 4e40ecc1..c511b9a9 100644 --- a/internal/generate/marshal_json.go +++ b/internal/generate/marshal_json.go @@ -6,6 +6,7 @@ import ( "go/token" "go/types" "net/http" + "slices" "github.com/typelate/muxt/internal/astgen" "github.com/typelate/muxt/internal/muxt" @@ -19,10 +20,10 @@ import ( // result is written as application/json; on any recorded error the rendered // output is sent as the usual text/html fallback. func marshalJSONHandlerFunc(file *File, config RoutesFileConfiguration, def muxt.Definition, sig *types.Signature, resultDataIdent, receiverInterfaceName, bufIdent, statusCodeIdent string) (*ast.FuncLit, error) { - for _, arg := range def.Arguments { - if arg.Type == muxt.ArgumentTypeExecute && arg.Identifier == muxt.TemplateNameScopeIdentifierExecute { - return nil, fmt.Errorf("marshalJSON does not support the execute callback") - } + if slices.ContainsFunc(def.Arguments, func(arg muxt.Argument) bool { + return arg.Type == muxt.ArgumentTypeExecute && arg.Identifier == muxt.TemplateNameScopeIdentifierExecute + }) { + return nil, fmt.Errorf("marshalJSON does not support the execute callback") } return executeHTMLTemplateHandler(file, config, def, sig, resultDataIdent, receiverInterfaceName, bufIdent, statusCodeIdent, marshalJSONRespondStmts(file, resultDataIdent, bufIdent)...) } diff --git a/internal/generate/routes.go b/internal/generate/routes.go index 05ebfd37..50314be1 100644 --- a/internal/generate/routes.go +++ b/internal/generate/routes.go @@ -124,9 +124,8 @@ func TemplateRoutesFiles(wd string, config RoutesFileConfiguration, pkg source.P var ( receiverInterface = &ast.InterfaceType{Methods: new(ast.FieldList)} - templateSourceFiles = slices.Collect(maps.Keys(groups.byFile)) + templateSourceFiles = slices.Sorted(maps.Keys(groups.byFile)) ) - slices.Sort(templateSourceFiles) // Build main routes function routesFunc := &ast.FuncDecl{ @@ -332,9 +331,9 @@ func accumulateReceiverMethods(name string, sig *types.Signature, isMethod bool, if !isMethod { return nil } - if i := slices.IndexFunc(receiverInterface.Methods.List, func(field *ast.Field) bool { + if slices.ContainsFunc(receiverInterface.Methods.List, func(field *ast.Field) bool { return field.Names[0].Name == name - }); i >= 0 { + }) { return nil } exp, err := file.TypeASTExpression(sig) diff --git a/internal/generate/snapshot_test.go b/internal/generate/snapshot_test.go index c2f11092..2027f47f 100644 --- a/internal/generate/snapshot_test.go +++ b/internal/generate/snapshot_test.go @@ -8,6 +8,7 @@ import ( "go/parser" "go/token" "log" + "maps" "os" "path" "path/filepath" @@ -199,17 +200,13 @@ func writeSnapshot(t *testing.T, archivePath string, archive *txtar.Archive, got } } -func sortedKeys(maps ...map[string]string) []string { +func sortedKeys(ms ...map[string]string) []string { var keys []string - for _, m := range maps { - for key := range m { - if !slices.Contains(keys, key) { - keys = append(keys, key) - } - } + for _, m := range ms { + keys = slices.AppendSeq(keys, maps.Keys(m)) } slices.Sort(keys) - return keys + return slices.Compact(keys) } // unusedImports names the imports a generated file declares but does not diff --git a/internal/muxt/muxttest/muxttest.go b/internal/muxt/muxttest/muxttest.go index 86548d41..8053b220 100644 --- a/internal/muxt/muxttest/muxttest.go +++ b/internal/muxt/muxttest/muxttest.go @@ -20,8 +20,8 @@ import ( "go/parser" "go/token" "go/types" + "maps" "slices" - "sort" "testing" "github.com/typelate/muxt/internal/muxt" @@ -37,11 +37,7 @@ var FileSet = token.NewFileSet() // package is declared in it. func Check(t testing.TB, path string, files map[string]string) *types.Package { t.Helper() - names := make([]string, 0, len(files)) - for name := range files { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(files)) syntax := make([]*ast.File, 0, len(names)) for _, name := range names { file, err := parser.ParseFile(FileSet, name, files[name], parser.SkipObjectResolution)