├── genclient ├── utils.go ├── typescript │ └── generator.go ├── gendoc_test.go ├── testdata │ ├── gendoctest │ │ ├── tsconfig.json │ │ ├── gendoc.go │ │ └── client.ts │ ├── go.mod │ └── go.sum ├── showpaths.go ├── typescriptclient.tmpl ├── gendoc.go └── typescript.go ├── _example ├── todo │ ├── frontend │ │ ├── src │ │ │ ├── vite-env.d.ts │ │ │ ├── App.css │ │ │ ├── main.tsx │ │ │ ├── index.css │ │ │ ├── tasks.ts │ │ │ ├── App.tsx │ │ │ └── client.ts │ │ ├── bun.lockb │ │ ├── tsconfig.json │ │ ├── vite.config.ts │ │ ├── biome.json │ │ ├── .gitignore │ │ ├── index.html │ │ ├── tsconfig.node.json │ │ ├── tsconfig.app.json │ │ └── package.json │ ├── tools.go │ ├── README.md │ ├── go.mod │ ├── main.go │ └── go.sum ├── simple-use │ └── main.go └── simple-registry │ ├── go.mod │ ├── go.sum │ └── main.go ├── util.go ├── cmd ├── showpaths │ └── main.go ├── gentypescript │ └── main.go └── tanukiup │ └── main.go ├── sessions ├── sessions.go └── gorilla │ └── sessions.go ├── .gitignore ├── logger.go ├── LICENSE ├── accesslog.go ├── internal └── requestid │ └── requestid.go ├── go.mod ├── validator.go ├── error.go ├── handler.go ├── server.go ├── router.go ├── context.go ├── auth └── oidc │ └── oidc.go ├── go.sum ├── router_test.go ├── codec.go ├── tanukiup └── tanukiup.go └── README.md /genclient/utils.go: -------------------------------------------------------------------------------- 1 | package genclient 2 | -------------------------------------------------------------------------------- /genclient/typescript/generator.go: -------------------------------------------------------------------------------- 1 | package typescript 2 | -------------------------------------------------------------------------------- /_example/todo/frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /_example/todo/frontend/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mackee/tanukirpc/HEAD/_example/todo/frontend/bun.lockb -------------------------------------------------------------------------------- /_example/todo/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | package main 3 | 4 | import ( 5 | _ "github.com/mackee/tanukirpc/cmd/tanukiup" 6 | ) 7 | -------------------------------------------------------------------------------- /_example/todo/frontend/src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | max-width: 1280px; 3 | margin: 0 auto; 4 | padding: 2rem; 5 | text-align: center; 6 | } 7 | 8 | -------------------------------------------------------------------------------- /_example/todo/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package tanukirpc 2 | 3 | import ( 4 | "github.com/go-chi/chi/v5" 5 | ) 6 | 7 | func URLParam[Reg any](ctx Context[Reg], name string) string { 8 | return chi.URLParam(ctx.Request(), name) 9 | } 10 | -------------------------------------------------------------------------------- /_example/todo/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /cmd/showpaths/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/mackee/tanukirpc/genclient" 5 | "golang.org/x/tools/go/analysis/singlechecker" 6 | ) 7 | 8 | func main() { 9 | singlechecker.Main(genclient.ShowPathAnalyzer) 10 | } 11 | -------------------------------------------------------------------------------- /cmd/gentypescript/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/mackee/tanukirpc/genclient" 5 | "golang.org/x/tools/go/analysis/singlechecker" 6 | ) 7 | 8 | func main() { 9 | singlechecker.Main(genclient.TypeScriptClientGenerator) 10 | } 11 | -------------------------------------------------------------------------------- /_example/todo/frontend/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /_example/todo/frontend/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import App from "./App.tsx"; 4 | import "./index.css"; 5 | 6 | //biome-ignore lint:lint/style/noNonNullAssertion 7 | createRoot(document.getElementById("root")!).render( 8 | 9 | 10 | , 11 | ); 12 | -------------------------------------------------------------------------------- /genclient/gendoc_test.go: -------------------------------------------------------------------------------- 1 | package genclient_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mackee/tanukirpc/genclient" 7 | "golang.org/x/tools/go/analysis/analysistest" 8 | ) 9 | 10 | func TestGenerateTypeScriptClient(t *testing.T) { 11 | testdata := analysistest.TestData() 12 | analysistest.Run(t, testdata, genclient.TypeScriptClientGenerator, "./gendoctest") 13 | } 14 | -------------------------------------------------------------------------------- /_example/todo/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /_example/todo/frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /_example/todo/frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": true, 10 | "isolatedModules": true, 11 | "moduleDetection": "force", 12 | "noEmit": true, 13 | 14 | "strict": true, 15 | "noUnusedLocals": true, 16 | "noUnusedParameters": true, 17 | "noFallthroughCasesInSwitch": true 18 | }, 19 | "include": ["vite.config.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /_example/todo/README.md: -------------------------------------------------------------------------------- 1 | ## TODO App Example 2 | 3 | This is a simple example of a TODO Task App using tanukirpc with gentypescript and React. 4 | 5 | ### Requirements 6 | 7 | - Go 1.22 or later 8 | - Bun 1.1.24 or later 9 | 10 | ### Installation 11 | 12 | #### launch server 13 | 14 | ```bash 15 | $ go run github.com/mackee/tanukirpc/cmd/tanukiup -dir ./ 16 | ``` 17 | 18 | #### launch frontend 19 | 20 | ```bash 21 | $ cd frontend 22 | $ bun install 23 | $ bun run dev 24 | ``` 25 | 26 | ### Note 27 | 28 | * This example use on memory DB, so all data will be lost when the server is restarted. 29 | -------------------------------------------------------------------------------- /_example/todo/frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | "jsx": "react-jsx", 15 | 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["src"] 22 | } 23 | -------------------------------------------------------------------------------- /sessions/sessions.go: -------------------------------------------------------------------------------- 1 | package sessions 2 | 3 | import "net/http" 4 | 5 | // ReqResp is an interface for request and response. uses for SessionAccessor. 6 | type ReqResp interface { 7 | Request() *http.Request 8 | Response() http.ResponseWriter 9 | } 10 | 11 | // Accessor is an interface for session access. 12 | type Accessor interface { 13 | Set(key, value any) error 14 | Get(key string) (any, bool) 15 | Remove(key string) error 16 | Save(ctx ReqResp) error 17 | } 18 | 19 | type RegistryWithAccessor interface { 20 | Session() Accessor 21 | } 22 | 23 | type Store interface { 24 | GetAccessor(req *http.Request) (Accessor, error) 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | go.work.sum 23 | 24 | # env file 25 | .env 26 | 27 | .tool-versions 28 | -------------------------------------------------------------------------------- /genclient/testdata/gendoctest/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true 21 | }, 22 | "include": ["src"] 23 | } 24 | -------------------------------------------------------------------------------- /_example/simple-use/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/mackee/tanukirpc" 8 | ) 9 | 10 | type helloRequest struct { 11 | Name string `urlparam:"name"` 12 | } 13 | 14 | type helloResponse struct { 15 | Message string `json:"message"` 16 | } 17 | 18 | func hello(ctx tanukirpc.Context[struct{}], req helloRequest) (*helloResponse, error) { 19 | return &helloResponse{ 20 | Message: fmt.Sprintf("Hello, %s!", req.Name), 21 | }, nil 22 | } 23 | 24 | func main() { 25 | r := tanukirpc.NewRouter(struct{}{}) 26 | r.Get("/hello/{name}", tanukirpc.NewHandler(hello)) 27 | 28 | if err := r.ListenAndServe(context.Background(), ":8080"); err != nil { 29 | fmt.Println(err) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /_example/todo/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "biome lint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "react": "^18.3.1", 14 | "react-dom": "^18.3.1", 15 | "react-icons": "^5.3.0", 16 | "swr": "^2.2.5" 17 | }, 18 | "devDependencies": { 19 | "@biomejs/biome": "1.8.3", 20 | "@eslint/js": "^9.9.0", 21 | "@types/react": "^18.3.3", 22 | "@types/react-dom": "^18.3.0", 23 | "@vitejs/plugin-react": "^4.3.1", 24 | "globals": "^15.9.0", 25 | "typescript": "^5.5.3", 26 | "vite": "^5.4.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /_example/simple-registry/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mackee/tanukirpc/_example/simple-registry 2 | 3 | go 1.22.4 4 | 5 | require ( 6 | github.com/go-chi/chi/v5 v5.1.0 7 | github.com/mackee/tanukirpc v0.0.0-20240730100544-d1bd0e828fe7 8 | github.com/mattn/go-sqlite3 v1.14.22 9 | ) 10 | 11 | require ( 12 | github.com/ajg/form v1.5.1 // indirect 13 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 14 | github.com/go-chi/render v1.0.3 // indirect 15 | github.com/go-playground/locales v0.14.1 // indirect 16 | github.com/go-playground/universal-translator v0.18.1 // indirect 17 | github.com/go-playground/validator/v10 v10.22.0 // indirect 18 | github.com/hetiansu5/urlquery v1.2.7 // indirect 19 | github.com/leodido/go-urn v1.4.0 // indirect 20 | golang.org/x/crypto v0.25.0 // indirect 21 | golang.org/x/net v0.27.0 // indirect 22 | golang.org/x/sys v0.22.0 // indirect 23 | golang.org/x/text v0.16.0 // indirect 24 | ) 25 | 26 | replace github.com/mackee/tanukirpc => ../.. 27 | -------------------------------------------------------------------------------- /genclient/testdata/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mackee/tanukirpc/testdata 2 | 3 | go 1.22.4 4 | 5 | require github.com/mackee/tanukirpc v0.0.0-20240801063458-86cabcae4c36 6 | 7 | require ( 8 | github.com/ajg/form v1.5.1 // indirect 9 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 10 | github.com/go-chi/chi/v5 v5.1.0 // indirect 11 | github.com/go-chi/render v1.0.3 // indirect 12 | github.com/go-playground/locales v0.14.1 // indirect 13 | github.com/go-playground/universal-translator v0.18.1 // indirect 14 | github.com/go-playground/validator/v10 v10.22.0 // indirect 15 | github.com/gostaticanalysis/analysisutil v0.7.1 // indirect 16 | github.com/gostaticanalysis/comment v1.4.2 // indirect 17 | github.com/hetiansu5/urlquery v1.2.7 // indirect 18 | github.com/leodido/go-urn v1.4.0 // indirect 19 | golang.org/x/crypto v0.25.0 // indirect 20 | golang.org/x/net v0.27.0 // indirect 21 | golang.org/x/sys v0.22.0 // indirect 22 | golang.org/x/text v0.16.0 // indirect 23 | golang.org/x/tools v0.23.0 // indirect 24 | ) 25 | 26 | replace github.com/mackee/tanukirpc => ../../ 27 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package tanukirpc 2 | 3 | import ( 4 | gocontext "context" 5 | "fmt" 6 | "log/slog" 7 | 8 | "github.com/mackee/tanukirpc/internal/requestid" 9 | ) 10 | 11 | var defaultLoggerKeys = []fmt.Stringer{requestid.RequestIDKey} 12 | 13 | type loggerHandler struct { 14 | slog.Handler 15 | keys []fmt.Stringer 16 | } 17 | 18 | func (l *loggerHandler) Handle(ctx gocontext.Context, record slog.Record) error { 19 | for _, key := range l.keys { 20 | if v := ctx.Value(key); v != nil { 21 | record.AddAttrs(slog.Any(key.String(), v)) 22 | } 23 | } 24 | return l.Handler.Handle(ctx, record) 25 | } 26 | 27 | // NewLogger returns a new logger with the given logger. 28 | // This logger output with the informwation with request ID. 29 | // If the given logger is nil, it returns use the default logger. 30 | // keys is the whitelist of keys that use read from context.Context. 31 | func NewLogger(logger *slog.Logger, keys []fmt.Stringer) *slog.Logger { 32 | if logger == nil { 33 | logger = slog.Default() 34 | } 35 | return slog.New(&loggerHandler{ 36 | Handler: logger.Handler(), 37 | keys: keys, 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024- mackee 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /genclient/showpaths.go: -------------------------------------------------------------------------------- 1 | package genclient 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | 8 | "golang.org/x/tools/go/analysis" 9 | ) 10 | 11 | var ShowPathAnalyzer = &analysis.Analyzer{ 12 | Name: "showpaths", 13 | Doc: "show paths", 14 | Run: runShowPaths, 15 | Requires: []*analysis.Analyzer{ 16 | Analyzer, 17 | }, 18 | } 19 | 20 | func runShowPaths(pass *analysis.Pass) (any, error) { 21 | result := pass.ResultOf[Analyzer].(*AnalyzerResult) 22 | rps := result.RoutePaths 23 | if len(rps) == 0 { 24 | return nil, nil 25 | } 26 | 27 | rpps := make([]showPathPath, 0, len(rps)) 28 | for _, rp := range rps { 29 | rpps = append(rpps, showPathPath{ 30 | Method: rp.Method(), 31 | Path: rp.Path(), 32 | }) 33 | } 34 | jsonRet := showPathResult{ 35 | Paths: rpps, 36 | } 37 | if err := json.NewEncoder(os.Stdout).Encode(jsonRet); err != nil { 38 | return nil, fmt.Errorf("failed to encode json: %w", err) 39 | } 40 | 41 | return nil, nil 42 | } 43 | 44 | type showPathResult struct { 45 | Paths []showPathPath `json:"paths"` 46 | } 47 | 48 | type showPathPath struct { 49 | Method string `json:"method"` 50 | Path string `json:"path"` 51 | } 52 | -------------------------------------------------------------------------------- /_example/todo/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mackee/tanukirpc/_example/todo 2 | 3 | go 1.22.4 4 | 5 | replace github.com/mackee/tanukirpc => ../.. 6 | 7 | require ( 8 | github.com/go-chi/cors v1.2.1 9 | github.com/mackee/tanukirpc v0.2.0 10 | ) 11 | 12 | require ( 13 | github.com/ajg/form v1.5.1 // indirect 14 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 15 | github.com/fsnotify/fsnotify v1.7.0 // indirect 16 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 17 | github.com/go-chi/chi/v5 v5.1.0 // indirect 18 | github.com/go-chi/render v1.0.3 // indirect 19 | github.com/go-playground/locales v0.14.1 // indirect 20 | github.com/go-playground/universal-translator v0.18.1 // indirect 21 | github.com/go-playground/validator/v10 v10.22.0 // indirect 22 | github.com/gostaticanalysis/analysisutil v0.7.1 // indirect 23 | github.com/gostaticanalysis/comment v1.4.2 // indirect 24 | github.com/hetiansu5/urlquery v1.2.7 // indirect 25 | github.com/leodido/go-urn v1.4.0 // indirect 26 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 27 | github.com/urfave/cli/v2 v2.27.4 // indirect 28 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 29 | golang.org/x/crypto v0.25.0 // indirect 30 | golang.org/x/net v0.27.0 // indirect 31 | golang.org/x/sys v0.22.0 // indirect 32 | golang.org/x/text v0.16.0 // indirect 33 | golang.org/x/tools v0.23.0 // indirect 34 | ) 35 | -------------------------------------------------------------------------------- /accesslog.go: -------------------------------------------------------------------------------- 1 | package tanukirpc 2 | 3 | import ( 4 | gocontext "context" 5 | "log/slog" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | type AccessLogger interface { 11 | Log(ctx gocontext.Context, logger *slog.Logger, ww WrapResponseWriter, req *http.Request, err error, t1 time.Time, t2 time.Time) error 12 | } 13 | 14 | type WrapResponseWriter interface { 15 | http.ResponseWriter 16 | Status() int 17 | BytesWritten() int 18 | } 19 | 20 | type accessLogger struct{} 21 | 22 | func (a *accessLogger) Log(ctx gocontext.Context, logger *slog.Logger, ww WrapResponseWriter, req *http.Request, err error, t1 time.Time, t2 time.Time) error { 23 | reqHostHeader := req.Header.Get("Host") 24 | reqContentType := req.Header.Get("Content-Type") 25 | respContentType := ww.Header().Get("Content-Type") 26 | 27 | logger.InfoContext(ctx, "accesslog", 28 | slog.String("host", reqHostHeader), 29 | slog.String("method", req.Method), 30 | slog.String("path", req.URL.String()), 31 | slog.String("proto", req.Proto), 32 | slog.String("remote", req.RemoteAddr), 33 | slog.String("request_content_type", reqContentType), 34 | slog.String("response_content_type", respContentType), 35 | slog.Int("status", ww.Status()), 36 | slog.Int("size", ww.BytesWritten()), 37 | slog.String("process_time", t2.Sub(t1).String()), 38 | slog.Time("start", t1), 39 | slog.Time("end", t2), 40 | slog.Bool("error", err != nil), 41 | ) 42 | 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /_example/todo/frontend/src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | } 15 | 16 | a { 17 | font-weight: 500; 18 | color: #646cff; 19 | text-decoration: inherit; 20 | } 21 | a:hover { 22 | color: #535bf2; 23 | } 24 | 25 | body { 26 | margin: 0; 27 | display: flex; 28 | min-width: 320px; 29 | min-height: 100vh; 30 | } 31 | 32 | h1 { 33 | font-size: 3.2em; 34 | line-height: 1.1; 35 | } 36 | 37 | button { 38 | border-radius: 8px; 39 | border: 1px solid transparent; 40 | padding: 0.6em 1.2em; 41 | font-size: 1em; 42 | font-weight: 500; 43 | font-family: inherit; 44 | background-color: #1a1a1a; 45 | cursor: pointer; 46 | transition: border-color 0.25s; 47 | } 48 | button:hover { 49 | border-color: #646cff; 50 | } 51 | button:focus, 52 | button:focus-visible { 53 | outline: 4px auto -webkit-focus-ring-color; 54 | } 55 | 56 | @media (prefers-color-scheme: light) { 57 | :root { 58 | color: #213547; 59 | background-color: #ffffff; 60 | } 61 | a:hover { 62 | color: #747bff; 63 | } 64 | button { 65 | background-color: #f9f9f9; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /internal/requestid/requestid.go: -------------------------------------------------------------------------------- 1 | package requestid 2 | 3 | import ( 4 | gocontext "context" 5 | "crypto/rand" 6 | "encoding/base64" 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "strings" 11 | "sync/atomic" 12 | ) 13 | 14 | var ( 15 | prefix string 16 | reqid uint64 17 | ) 18 | 19 | // from github.com/go-chi/chi/v5/middleware/request_id.go 20 | func init() { 21 | hostname, err := os.Hostname() 22 | if hostname == "" || err != nil { 23 | hostname = "localhost" 24 | } 25 | var buf [12]byte 26 | var b64 string 27 | for len(b64) < 10 { 28 | rand.Read(buf[:]) 29 | b64 = base64.StdEncoding.EncodeToString(buf[:]) 30 | b64 = strings.NewReplacer("+", "", "/", "").Replace(b64) 31 | } 32 | 33 | prefix = fmt.Sprintf("%s/%s", hostname, b64[0:10]) 34 | } 35 | 36 | type requestIDCtxKey string 37 | 38 | func (r requestIDCtxKey) String() string { 39 | return string(r) 40 | } 41 | 42 | const ( 43 | RequestIDHeader = "X-Request-ID" 44 | RequestIDKey = requestIDCtxKey("request_id") 45 | ) 46 | 47 | func Middleware(next http.Handler) http.Handler { 48 | fn := func(w http.ResponseWriter, r *http.Request) { 49 | ctx := r.Context() 50 | requestID := r.Header.Get(RequestIDHeader) 51 | if requestID == "" { 52 | myid := atomic.AddUint64(&reqid, 1) 53 | requestID = fmt.Sprintf("%s-%06d", prefix, myid) 54 | } 55 | ctx = gocontext.WithValue(ctx, RequestIDKey, requestID) 56 | next.ServeHTTP(w, r.WithContext(ctx)) 57 | } 58 | return http.HandlerFunc(fn) 59 | } 60 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mackee/tanukirpc 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.2 6 | 7 | require ( 8 | github.com/coreos/go-oidc/v3 v3.11.0 9 | github.com/fsnotify/fsnotify v1.7.0 10 | github.com/go-chi/chi/v5 v5.1.0 11 | github.com/go-chi/render v1.0.3 12 | github.com/go-playground/validator/v10 v10.22.0 13 | github.com/google/uuid v1.6.0 14 | github.com/gorilla/sessions v1.4.0 15 | github.com/gostaticanalysis/analysisutil v0.7.1 16 | github.com/hetiansu5/urlquery v1.2.7 17 | github.com/stretchr/testify v1.9.0 18 | github.com/urfave/cli/v2 v2.27.4 19 | golang.org/x/oauth2 v0.23.0 20 | golang.org/x/tools v0.23.0 21 | ) 22 | 23 | require ( 24 | github.com/ajg/form v1.5.1 // indirect 25 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 28 | github.com/go-jose/go-jose/v4 v4.0.2 // indirect 29 | github.com/go-playground/locales v0.14.1 // indirect 30 | github.com/go-playground/universal-translator v0.18.1 // indirect 31 | github.com/gorilla/securecookie v1.1.2 // indirect 32 | github.com/gostaticanalysis/comment v1.4.2 // indirect 33 | github.com/leodido/go-urn v1.4.0 // indirect 34 | github.com/pmezard/go-difflib v1.0.0 // indirect 35 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 36 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 37 | golang.org/x/crypto v0.25.0 // indirect 38 | golang.org/x/mod v0.19.0 // indirect 39 | golang.org/x/net v0.27.0 // indirect 40 | golang.org/x/sync v0.7.0 // indirect 41 | golang.org/x/sys v0.22.0 // indirect 42 | golang.org/x/text v0.16.0 // indirect 43 | gopkg.in/yaml.v3 v3.0.1 // indirect 44 | ) 45 | -------------------------------------------------------------------------------- /sessions/gorilla/sessions.go: -------------------------------------------------------------------------------- 1 | package gorilla 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | gorillasessions "github.com/gorilla/sessions" 8 | "github.com/mackee/tanukirpc/sessions" 9 | ) 10 | 11 | type gorillaStore struct { 12 | store gorillasessions.Store 13 | sessionName string 14 | } 15 | 16 | type Option func(*gorillaStore) 17 | 18 | func WithSessionName(name string) Option { 19 | return func(o *gorillaStore) { 20 | o.sessionName = name 21 | } 22 | } 23 | 24 | // NewStore returns a new Gorilla session store. 25 | func NewStore(store gorillasessions.Store, opts ...Option) (sessions.Store, error) { 26 | gs := &gorillaStore{ 27 | store: store, 28 | sessionName: "session", 29 | } 30 | for _, opt := range opts { 31 | opt(gs) 32 | } 33 | 34 | return gs, nil 35 | } 36 | 37 | // GetAccessor returns an accessor. 38 | func (s *gorillaStore) GetAccessor(req *http.Request) (sessions.Accessor, error) { 39 | session, err := s.store.Get(req, s.sessionName) 40 | if err != nil { 41 | return nil, fmt.Errorf("failed to get session: %w", err) 42 | } 43 | 44 | return &accessor{ 45 | store: s, 46 | session: session, 47 | }, nil 48 | } 49 | 50 | type accessor struct { 51 | store *gorillaStore 52 | session *gorillasessions.Session 53 | } 54 | 55 | // Set sets a value to the session. 56 | func (s *accessor) Set(key, value any) error { 57 | s.session.Values[key] = value 58 | return nil 59 | } 60 | 61 | // Get gets a value from the session. 62 | func (s *accessor) Get(key string) (any, bool) { 63 | value, ok := s.session.Values[key] 64 | return value, ok 65 | } 66 | 67 | // Remove removes a value from the session. 68 | func (s *accessor) Remove(key string) error { 69 | delete(s.session.Values, key) 70 | return nil 71 | } 72 | 73 | // Save saves the session. 74 | func (s *accessor) Save(ctx sessions.ReqResp) error { 75 | if err := s.session.Save(ctx.Request(), ctx.Response()); err != nil { 76 | return fmt.Errorf("failed to save session: %w", err) 77 | } 78 | return nil 79 | } 80 | -------------------------------------------------------------------------------- /validator.go: -------------------------------------------------------------------------------- 1 | package tanukirpc 2 | 3 | import ( 4 | "net/http" 5 | "reflect" 6 | "sync" 7 | 8 | "github.com/go-playground/validator/v10" 9 | ) 10 | 11 | type Validatable interface { 12 | Validate() error 13 | } 14 | 15 | func canValidate(req any) (Validatable, bool) { 16 | v, ok := req.(Validatable) 17 | if ok { 18 | return v, true 19 | } 20 | if hasValidateTag(req) { 21 | return newStructValidator(req), true 22 | } 23 | return v, ok 24 | } 25 | 26 | type ValidateError struct { 27 | err error 28 | } 29 | 30 | func (v *ValidateError) Status() int { 31 | if ews, ok := v.err.(ErrorWithStatus); ok { 32 | return ews.Status() 33 | } 34 | return http.StatusBadRequest 35 | } 36 | 37 | func (v *ValidateError) Error() string { 38 | return v.err.Error() 39 | } 40 | 41 | func (v *ValidateError) Unwrap() error { 42 | return v.err 43 | } 44 | 45 | func hasValidateTag(req any) bool { 46 | v := reflect.ValueOf(req) 47 | t := v.Type() 48 | if v.Kind() == reflect.Pointer && v.IsNil() { 49 | return false 50 | } 51 | if v.Kind() == reflect.Pointer { 52 | v = v.Elem() 53 | t = t.Elem() 54 | } 55 | if t.Kind() != reflect.Struct { 56 | return false 57 | } 58 | for i := 0; i < v.NumField(); i++ { 59 | fv := v.Field(i) 60 | ft := t.Field(i) 61 | ftt := ft.Type 62 | if ftt.Kind() == reflect.Pointer { 63 | ftt = ftt.Elem() 64 | } 65 | if ftt.Kind() == reflect.Struct && ft.IsExported() { 66 | if hasValidateTag(fv.Interface()) { 67 | return true 68 | } 69 | } 70 | 71 | if _, ok := ft.Tag.Lookup("validate"); ok { 72 | return true 73 | } 74 | } 75 | return false 76 | } 77 | 78 | var defaultValidator = &sync.Pool{ 79 | New: func() any { 80 | return validator.New(validator.WithRequiredStructEnabled()) 81 | }, 82 | } 83 | 84 | type structValidator struct { 85 | req any 86 | val *validator.Validate 87 | } 88 | 89 | func newStructValidator(req any) *structValidator { 90 | return &structValidator{req: req, val: defaultValidator.Get().(*validator.Validate)} 91 | } 92 | 93 | func (s *structValidator) Validate() error { 94 | return s.val.Struct(s.req) 95 | } 96 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package tanukirpc 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log/slog" 7 | "net/http" 8 | ) 9 | 10 | type ErrorWithStatus interface { 11 | error 12 | Status() int 13 | } 14 | 15 | type errorWithStatus struct { 16 | status int 17 | err error 18 | } 19 | 20 | func (e *errorWithStatus) Error() string { 21 | return e.err.Error() 22 | } 23 | 24 | func (e *errorWithStatus) Status() int { 25 | return e.status 26 | } 27 | 28 | func (e *errorWithStatus) Unwrap() error { 29 | return e.err 30 | } 31 | 32 | func WrapErrorWithStatus(status int, err error) error { 33 | return &errorWithStatus{status: status, err: err} 34 | } 35 | 36 | type ErrorWithRedirect interface { 37 | error 38 | Status() int 39 | Redirect() string 40 | } 41 | 42 | type errorWithRedirect struct { 43 | status int 44 | redirect string 45 | } 46 | 47 | func (e *errorWithRedirect) Error() string { 48 | return fmt.Sprintf("redirect to %s", e.redirect) 49 | } 50 | 51 | func (e *errorWithRedirect) Status() int { 52 | return e.status 53 | } 54 | 55 | func (e *errorWithRedirect) Redirect() string { 56 | return e.redirect 57 | } 58 | 59 | func ErrorRedirectTo(status int, redirect string) error { 60 | return &errorWithRedirect{status: status, redirect: redirect} 61 | } 62 | 63 | type ErrorMessage struct { 64 | Error ErrorBody `json:"error"` 65 | } 66 | 67 | type ErrorBody struct { 68 | Message string `json:"message"` 69 | } 70 | 71 | type ErrorHooker interface { 72 | OnError(w http.ResponseWriter, req *http.Request, logger *slog.Logger, codec Codec, err error) 73 | } 74 | 75 | type errorHooker struct{} 76 | 77 | func (e *errorHooker) OnError(w http.ResponseWriter, req *http.Request, logger *slog.Logger, codec Codec, err error) { 78 | var ewr ErrorWithRedirect 79 | if errors.As(err, &ewr) { 80 | http.Redirect(w, req, ewr.Redirect(), ewr.Status()) 81 | return 82 | } 83 | var ews ErrorWithStatus 84 | if errors.As(err, &ews) { 85 | w.WriteHeader(ews.Status()) 86 | } else { 87 | w.WriteHeader(http.StatusInternalServerError) 88 | logger.ErrorContext(req.Context(), "ocurred internal server error", slog.Any("error", err)) 89 | } 90 | codec.Encode(w, req, ErrorMessage{Error: ErrorBody{Message: err.Error()}}) 91 | } 92 | -------------------------------------------------------------------------------- /handler.go: -------------------------------------------------------------------------------- 1 | package tanukirpc 2 | 3 | import ( 4 | "log/slog" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/go-chi/chi/v5/middleware" 9 | ) 10 | 11 | type Handler[Reg any] interface { 12 | build(r *Router[Reg]) http.HandlerFunc 13 | } 14 | 15 | func NewHandler[Req any, Res any, Reg any](h HandlerFunc[Req, Res, Reg]) Handler[Reg] { 16 | return &handler[Req, Res, Reg]{h: h} 17 | } 18 | 19 | type HandlerFunc[Req any, Res any, Reg any] func(Context[Reg], Req) (Res, error) 20 | 21 | type handler[Req any, Res any, T any] struct { 22 | h HandlerFunc[Req, Res, T] 23 | } 24 | 25 | func (h *handler[Req, Res, Reg]) build(r *Router[Reg]) http.HandlerFunc { 26 | return func(w http.ResponseWriter, req *http.Request) { 27 | ww := middleware.NewWrapResponseWriter(w, req.ProtoMajor) 28 | t1 := time.Now() 29 | var t2 time.Time 30 | var lerr error 31 | defer func() { 32 | if t2.IsZero() { 33 | t2 = time.Now() 34 | } 35 | if err := r.accessLoggerLog(req.Context(), ww, req, lerr, t1, t2); err != nil { 36 | r.logger.ErrorContext(req.Context(), "access log error", slog.Any("error", err)) 37 | } 38 | }() 39 | 40 | var reqBody Req 41 | if err := r.codec.Decode(req, &reqBody); err != nil { 42 | r.errorHooker.OnError(ww, req, r.logger, r.codec, err) 43 | lerr = err 44 | return 45 | } 46 | if vreq, ok := canValidate(reqBody); ok { 47 | if err := vreq.Validate(); err != nil { 48 | ve := &ValidateError{err: err} 49 | r.errorHooker.OnError(ww, req, r.logger, r.codec, ve) 50 | lerr = err 51 | return 52 | } 53 | } 54 | 55 | ctx, err := r.contextFactory.Build(ww, req) 56 | if err != nil { 57 | r.errorHooker.OnError(ww, req, r.logger, r.codec, err) 58 | lerr = err 59 | return 60 | } 61 | 62 | res, err := h.h(ctx, reqBody) 63 | if err := ctx.DeferDo(DeferDoTimingBeforeCheckError); err != nil { 64 | r.errorHooker.OnError(ww, req, r.logger, r.codec, err) 65 | lerr = err 66 | return 67 | } 68 | slog.InfoContext(ctx, "request completed") 69 | if err != nil { 70 | r.errorHooker.OnError(ww, req, r.logger, r.codec, err) 71 | lerr = err 72 | return 73 | } 74 | 75 | if err := ctx.DeferDo(DeferDoTimingBeforeResponse); err != nil { 76 | r.errorHooker.OnError(ww, req, r.logger, r.codec, err) 77 | lerr = err 78 | return 79 | } 80 | 81 | if ww.Status() == 0 { 82 | if err := r.codec.Encode(ww, req, res); err != nil { 83 | r.errorHooker.OnError(ww, req, r.logger, r.codec, err) 84 | lerr = err 85 | return 86 | } 87 | } 88 | t2 = time.Now() 89 | 90 | if err := ctx.DeferDo(DeferDoTimingAfterResponse); err != nil { 91 | r.logger.ErrorContext(ctx, "defer do error", slog.Any("error", err)) 92 | lerr = err 93 | return 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /genclient/testdata/gendoctest/gendoc.go: -------------------------------------------------------------------------------- 1 | package gendoctest 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/mackee/tanukirpc" 7 | "github.com/mackee/tanukirpc/genclient" 8 | ) 9 | 10 | //go:generate go run github.com/mackee/tanukirpc/cmd/gentypescript -out ./client.ts ./ 11 | 12 | const ( 13 | echoPath = "/echo" 14 | ) 15 | 16 | func testGendoc() { 17 | router := newTestGendocRouter() 18 | type echoRequest struct { 19 | Message string `json:"message" validate:"required"` 20 | } 21 | type echoResponse struct { 22 | Message *string `json:"message"` 23 | } 24 | router.Get( 25 | echoPath, 26 | tanukirpc.NewHandler( 27 | func(ctx tanukirpc.Context[struct{}], req *echoRequest) (*echoResponse, error) { 28 | return &echoResponse{Message: &req.Message}, nil 29 | }, 30 | ), 31 | ) 32 | router.Route("/nested", func(r *tanukirpc.Router[struct{}]) { 33 | type nowResponse struct { 34 | Now string `json:"now"` 35 | } 36 | r.Get("/now", tanukirpc.NewHandler( 37 | func(ctx tanukirpc.Context[struct{}], _ struct{}) (*nowResponse, error) { 38 | return &nowResponse{Now: time.Now().String()}, nil 39 | }, 40 | )) 41 | r.Get("/{epoch:[0-9]+}", tanukirpc.NewHandler(epochHandler)) 42 | }) 43 | 44 | genclient.AnalyzeTarget(router) 45 | } 46 | 47 | type exampleTestRouterRegistry struct { 48 | pingCounter int 49 | } 50 | 51 | func newTestGendocRouter() *tanukirpc.Router[struct{}] { 52 | router := tanukirpc.NewRouter(struct{}{}) 53 | transformer := tanukirpc.NewTransformer( 54 | func(_ tanukirpc.Context[struct{}]) (*exampleTestRouterRegistry, error) { 55 | return &exampleTestRouterRegistry{}, nil 56 | }, 57 | ) 58 | 59 | type pingResponse struct { 60 | Message string `json:"message"` 61 | } 62 | type pingCounterResponse struct { 63 | Count int `json:"count"` 64 | } 65 | pingPostHandler := func(ctx tanukirpc.Context[*exampleTestRouterRegistry], _ struct{}) (*pingResponse, error) { 66 | ctx.Registry().pingCounter++ 67 | return &pingResponse{"pong"}, nil 68 | } 69 | pingGetHandler := func(ctx tanukirpc.Context[*exampleTestRouterRegistry], _ struct{}) (*pingCounterResponse, error) { 70 | count := ctx.Registry().pingCounter 71 | return &pingCounterResponse{count}, nil 72 | } 73 | 74 | tanukirpc.RouteWithTransformer(router, transformer, "/ping", func(r *tanukirpc.Router[*exampleTestRouterRegistry]) { 75 | r.Post("/", tanukirpc.NewHandler(pingPostHandler)) 76 | r.Get("/", tanukirpc.NewHandler(pingGetHandler)) 77 | r.Route("/nested", func(r *tanukirpc.Router[*exampleTestRouterRegistry]) { 78 | r.Get("/", tanukirpc.NewHandler(pingGetHandler)) 79 | }) 80 | }) 81 | 82 | return router 83 | } 84 | 85 | type epochRequest struct { 86 | Epoch int64 `urlparam:"epoch"` 87 | } 88 | type epochResponse struct { 89 | Datetime string `json:"datetime"` 90 | } 91 | 92 | func epochHandler(ctx tanukirpc.Context[struct{}], req *epochRequest) (*epochResponse, error) { 93 | return &epochResponse{Datetime: time.Unix(req.Epoch, 0).String()}, nil 94 | } 95 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package tanukirpc 2 | 3 | import ( 4 | gocontext "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "net" 9 | "net/http" 10 | "os" 11 | "time" 12 | ) 13 | 14 | type listenAndServeConfig struct { 15 | disableTanukiupProxy bool 16 | shutdownTimeout time.Duration 17 | noSetDefaultLogger bool 18 | } 19 | 20 | type ListenAndServeOption func(*listenAndServeConfig) 21 | 22 | func WithDisableTanukiupProxy() ListenAndServeOption { 23 | return func(o *listenAndServeConfig) { 24 | o.disableTanukiupProxy = true 25 | } 26 | } 27 | 28 | func WithShutdownTimeout(d time.Duration) ListenAndServeOption { 29 | return func(o *listenAndServeConfig) { 30 | o.shutdownTimeout = d 31 | } 32 | } 33 | 34 | func WithNoSetDefaultLogger() ListenAndServeOption { 35 | return func(o *listenAndServeConfig) { 36 | o.noSetDefaultLogger = true 37 | } 38 | } 39 | 40 | // ListenAndServe starts the server. 41 | // If the context is canceled, the server will be shutdown. 42 | func (r *Router[Reg]) ListenAndServe(ctx gocontext.Context, addr string, opts ...ListenAndServeOption) error { 43 | cfg := &listenAndServeConfig{} 44 | for _, o := range opts { 45 | o(cfg) 46 | } 47 | 48 | server := &http.Server{ 49 | Addr: addr, 50 | Handler: r, 51 | } 52 | go func() { 53 | <-ctx.Done() 54 | rctx, cancel := gocontext.WithTimeout(gocontext.Background(), 5*time.Second) 55 | defer cancel() 56 | 57 | slog.InfoContext(ctx, "Server is shutting down...") 58 | if err := server.Shutdown(rctx); err != nil { 59 | slog.ErrorContext(ctx, "failed to shutdown server", slog.Any("error", err)) 60 | } 61 | }() 62 | var uds net.Listener 63 | if !cfg.disableTanukiupProxy { 64 | _uds, err := r.tanukiupUnixListener() 65 | if err != nil && !errors.Is(err, errTanukiupUDSNotFound) { 66 | return fmt.Errorf("failed to listen tanukiup unix domain socket: %w", err) 67 | } 68 | uds = _uds 69 | } 70 | 71 | if uds == nil { 72 | slog.InfoContext(ctx, "Server is starting...", slog.String("addr", addr)) 73 | if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 74 | return fmt.Errorf("failed to listen and serve: %w", err) 75 | } 76 | } else { 77 | slog.InfoContext(ctx, "Server is starting with unix domain socket...") 78 | if err := server.Serve(uds); err != nil && !errors.Is(err, http.ErrServerClosed) { 79 | return fmt.Errorf("failed to serve: %w", err) 80 | } 81 | } 82 | return nil 83 | } 84 | 85 | var errTanukiupUDSNotFound = errors.New("tanukiup unix domain socket not found") 86 | 87 | const ( 88 | tanukiupUDSPathEnv = "TANUKIUP_UDS_PATH" 89 | ) 90 | 91 | func (r *Router[Reg]) tanukiupUnixListener() (net.Listener, error) { 92 | p, ok := os.LookupEnv(tanukiupUDSPathEnv) 93 | if !ok { 94 | return nil, errTanukiupUDSNotFound 95 | } 96 | 97 | uds, err := net.Listen("unix", p) 98 | if err != nil { 99 | return nil, fmt.Errorf("failed to listen unix domain socket: %w", err) 100 | } 101 | return uds, nil 102 | } 103 | -------------------------------------------------------------------------------- /_example/simple-registry/go.sum: -------------------------------------------------------------------------------- 1 | github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= 2 | github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 6 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 7 | github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= 8 | github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 9 | github.com/go-chi/render v1.0.3 h1:AsXqd2a1/INaIfUSKq3G5uA8weYx20FOsM7uSoCyyt4= 10 | github.com/go-chi/render v1.0.3/go.mod h1:/gr3hVkmYR0YlEy3LxCuVRFzEu9Ruok+gFqbIofjao0= 11 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 12 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 13 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 14 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 15 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 16 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 17 | github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= 18 | github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 19 | github.com/hetiansu5/urlquery v1.2.7 h1:jn0h+9pIRqUziSPnRdK/gJK8S5TCnk+HZZx5fRHf8K0= 20 | github.com/hetiansu5/urlquery v1.2.7/go.mod h1:wFpZdTHRdwt7mk0EM/DdZEWtEN4xf8HJoH/BLXm/PG0= 21 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 22 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 23 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 24 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 25 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 26 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 28 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 29 | golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= 30 | golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 31 | golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= 32 | golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 33 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 34 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 35 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 36 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 37 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 38 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 39 | -------------------------------------------------------------------------------- /_example/simple-registry/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "fmt" 7 | "log/slog" 8 | "net/http" 9 | "os" 10 | 11 | "github.com/go-chi/chi/v5/middleware" 12 | "github.com/mackee/tanukirpc" 13 | _ "github.com/mattn/go-sqlite3" 14 | ) 15 | 16 | type registry struct { 17 | db *sql.DB 18 | logger *slog.Logger 19 | } 20 | 21 | type createAccountRequest struct { 22 | Name string `form:"name" validation:"required"` 23 | } 24 | 25 | type createAccountResponse struct { 26 | ID int `json:"id"` 27 | Name string `json:"name"` 28 | } 29 | 30 | func createAccount(ctx tanukirpc.Context[*registry], req createAccountRequest) (*createAccountResponse, error) { 31 | db := ctx.Registry().db 32 | var id int 33 | if err := db. 34 | QueryRowContext(ctx, "INSERT INTO accounts (name) VALUES (?) RETURNING id", req.Name). 35 | Scan(&id); err != nil { 36 | return nil, fmt.Errorf("failed to create account: %w", err) 37 | } 38 | ctx.Registry().logger.Info( 39 | "account created", 40 | slog.Group("account", 41 | slog.Int("id", id), 42 | slog.String("name", req.Name), 43 | ), 44 | ) 45 | 46 | return &createAccountResponse{ 47 | ID: id, 48 | Name: req.Name, 49 | }, nil 50 | } 51 | 52 | type accountRequest struct { 53 | ID int `urlparam:"id"` 54 | } 55 | 56 | type accountResponse struct { 57 | Name string `json:"name"` 58 | } 59 | 60 | func account(ctx tanukirpc.Context[*registry], req accountRequest) (*accountResponse, error) { 61 | db := ctx.Registry().db 62 | var name string 63 | if err := db.QueryRowContext(ctx, "SELECT name FROM accounts WHERE id = ?", req.ID).Scan(&name); err == sql.ErrNoRows { 64 | return nil, tanukirpc.WrapErrorWithStatus(http.StatusNotFound, err) 65 | } else if err != nil { 66 | return nil, fmt.Errorf("failed to get account: %w", err) 67 | } 68 | 69 | return &accountResponse{ 70 | Name: name, 71 | }, nil 72 | } 73 | 74 | func main() { 75 | ctx := context.Background() 76 | logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) 77 | db, err := sql.Open("sqlite3", "file::memory:?cache=shared") 78 | if err != nil { 79 | logger.Error("failed to open database", slog.Any("error", err)) 80 | return 81 | } 82 | defer db.Close() 83 | if _, err := db.ExecContext(ctx, "CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT)"); err != nil { 84 | logger.Error("failed to create table", slog.Any("error", err)) 85 | return 86 | } 87 | 88 | // Registry scoped to the process 89 | // r := tanukirpc.NewRouter(®istry{db: db, logger: logger}) 90 | // Registry scoped to the request 91 | r := tanukirpc.NewRouter( 92 | ®istry{}, 93 | tanukirpc.WithContextFactory( 94 | tanukirpc.NewContextHookFactory( 95 | func(w http.ResponseWriter, req *http.Request) (*registry, error) { 96 | reqID := middleware.GetReqID(req.Context()) 97 | reqIDLogger := logger.With(slog.String("req_id", reqID)) 98 | return ®istry{db: db, logger: reqIDLogger}, nil 99 | }, 100 | ), 101 | ), 102 | ) 103 | // You can use chi middleware or `func (http.Handler) http.Handler` 104 | r.Use(middleware.RequestID) 105 | 106 | r.Post("/accounts", tanukirpc.NewHandler(createAccount)) 107 | r.Get("/account/{id}", tanukirpc.NewHandler(account)) 108 | 109 | if err := r.ListenAndServe(ctx, ":8080"); err != nil { 110 | fmt.Println(err) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /_example/todo/frontend/src/tasks.ts: -------------------------------------------------------------------------------- 1 | import useSWR, { mutate } from "swr"; 2 | import { newClient, isErrorResponse } from "./client"; 3 | 4 | interface task { 5 | id: string; 6 | name: string; 7 | description: string; 8 | } 9 | 10 | const client = newClient(); 11 | 12 | export const useTasks = () => {}; 13 | 14 | export const fetchTasks = () => { 15 | const { data, error } = useSWR("/api/tasks", async () => { 16 | const data = await client.get("/api/tasks", {}); 17 | if (isErrorResponse(data)) { 18 | throw new Error(data.error.message); 19 | } 20 | if (!data.tasks) { 21 | return []; 22 | } 23 | 24 | return data.tasks.map((task) => { 25 | return { 26 | id: task.id, 27 | name: task.name, 28 | description: task.description, 29 | }; 30 | }); 31 | }); 32 | if (error) { 33 | throw error; 34 | } 35 | 36 | return { tasks: data || [] }; 37 | }; 38 | 39 | export const addTask = (task: { 40 | name: string; 41 | description: string; 42 | }) => { 43 | mutate( 44 | "/api/tasks", 45 | async () => { 46 | const data = await client.post("/api/tasks", { 47 | data: { 48 | task: { 49 | name: task.name, 50 | description: task.description, 51 | }, 52 | }, 53 | }); 54 | if (isErrorResponse(data)) { 55 | throw new Error(data.error.message); 56 | } 57 | return { 58 | id: data.task.id, 59 | name: data.task.name, 60 | description: data.task.description, 61 | }; 62 | }, 63 | { 64 | populateCache: (task: task, tasks: task[] | undefined) => { 65 | if (tasks === undefined) { 66 | return [task]; 67 | } 68 | return [task, ...tasks]; 69 | }, 70 | revalidate: false, 71 | }, 72 | ); 73 | }; 74 | 75 | export const deleteTask = (taskId: string) => { 76 | mutate( 77 | "/api/tasks", 78 | async () => { 79 | const data = await client.delete("/api/tasks/{id}", { 80 | pathArgs: { id: taskId }, 81 | }); 82 | if (isErrorResponse(data)) { 83 | throw new Error(data.error.message); 84 | } 85 | return data || { status: "error" }; 86 | }, 87 | { 88 | populateCache: ( 89 | result: { status: string }, 90 | tasks: task[] | undefined, 91 | ) => { 92 | if (result.status !== "ok") { 93 | return tasks || []; 94 | } 95 | return tasks?.filter((task) => task.id !== taskId) || []; 96 | }, 97 | revalidate: false, 98 | }, 99 | ); 100 | }; 101 | 102 | export const updateTask = async (task: task) => { 103 | return await mutate( 104 | "/api/tasks", 105 | async () => { 106 | const data = await client.put("/api/tasks/{id}", { 107 | pathArgs: { id: task.id }, 108 | data: { 109 | task: { 110 | name: task.name, 111 | description: task.description, 112 | status: "done", 113 | }, 114 | }, 115 | }); 116 | if (isErrorResponse(data)) { 117 | throw new Error(data.error.message); 118 | } 119 | return { 120 | id: data.task.id, 121 | name: data.task.name, 122 | description: data.task.description, 123 | }; 124 | }, 125 | { 126 | populateCache: (task: task, tasks: task[] | undefined) => { 127 | if (tasks === undefined) { 128 | return [task]; 129 | } 130 | const newTasks = tasks.map((t) => { 131 | if (t.id === task.id) { 132 | return task; 133 | } 134 | return t; 135 | }); 136 | return newTasks; 137 | }, 138 | revalidate: true, 139 | }, 140 | ); 141 | }; 142 | -------------------------------------------------------------------------------- /genclient/testdata/gendoctest/client.ts: -------------------------------------------------------------------------------- 1 | // This file was automatically @generated by gentypescript 2 | 3 | type apiSchemaCollection = { 4 | "POST /ping": { 5 | Query: undefined; 6 | Request: undefined; 7 | Response: { 8 | message: string 9 | }; 10 | }; 11 | "GET /ping": { 12 | Query: undefined; 13 | Request: undefined; 14 | Response: { 15 | count: number 16 | }; 17 | }; 18 | "GET /ping/nested": { 19 | Query: undefined; 20 | Request: undefined; 21 | Response: { 22 | count: number 23 | }; 24 | }; 25 | "GET /echo": { 26 | Query: undefined; 27 | Request: { 28 | message: string 29 | }; 30 | Response: { 31 | message?: string 32 | }; 33 | }; 34 | "GET /nested/now": { 35 | Query: undefined; 36 | Request: undefined; 37 | Response: { 38 | now: string 39 | }; 40 | }; 41 | "GET /nested/{epoch:[0-9]+}": { 42 | Query: undefined; 43 | Request: undefined; 44 | Response: { 45 | datetime: string 46 | }; 47 | }; 48 | } 49 | 50 | type method = keyof apiSchemaCollection extends `${infer M} ${string}` ? M : never; 51 | type methodPathsByMethod = Extract 52 | type pathByMethod = MP extends `${method} ${infer P}` ? P : never 53 | type pathsByMethod = pathByMethod> 54 | 55 | const hasApiRequest = (args: unknown): args is { data: apiSchemaCollection[PM]["Request"] } => { 56 | return !!(args as { data: unknown })?.data 57 | } 58 | 59 | const hasApiQuery = (args: unknown): args is { query: apiSchemaCollection[PM]["Query"] } => { 60 | return !!(args as { query: unknown })?.query 61 | } 62 | const apiPathBuilder = { 63 | "/nested/{epoch:[0-9]+}": (args: {epoch: string}) => `/nested/${args.epoch}`, 64 | } as const 65 | 66 | const hasApiPathBuilder = (path: string): path is keyof typeof apiPathBuilder => path in apiPathBuilder 67 | type apiPathBuilderArgs = K extends `${method} ${infer P}` ? (P extends keyof typeof apiPathBuilder ? Parameters[0] : never) : never 68 | 69 | const hasApiPathArgs = (args: unknown): args is { pathArgs: apiPathBuilderArgs } => { 70 | return !!(args as { pathArgs: unknown })?.pathArgs 71 | } 72 | 73 | type pathCallArgs = 74 | (apiSchemaCollection[PM]["Request"] extends undefined ? {} : { data: apiSchemaCollection[PM]["Request"] }) & 75 | (apiPathBuilderArgs extends never ? {} : { pathArgs: apiPathBuilderArgs }) & 76 | (apiSchemaCollection[PM]["Query"] extends undefined ? {} : { query: apiSchemaCollection[PM]["Query"]}) 77 | 78 | type client = { 79 | post:

>(path: P, args: pathCallArgs<`POST ${P}`>) => Promise 80 | get:

>(path: P, args: pathCallArgs<`GET ${P}`>) => Promise 81 | } 82 | 83 | export const newClient = (baseURL: string = ""): client => { 84 | const fetchByPath = async (method: method, path: string, args: pathCallArgs) => { 85 | const builtPath = hasApiPathBuilder(path) && hasApiPathArgs(args) ? apiPathBuilder[path](args.pathArgs) : path 86 | const query = hasApiQuery(args) ? "?" + (new URLSearchParams(args.query).toString()) : "" 87 | const body = hasApiRequest(args) ? JSON.stringify(args.data) : undefined 88 | const response = await fetch(baseURL + builtPath + query, { 89 | method, 90 | headers: { 91 | "Content-Type": "application/json", 92 | }, 93 | body, 94 | }) 95 | 96 | if (!response.ok) { 97 | throw new Error(response.statusText) 98 | } 99 | return response.json() as Promise 100 | } 101 | const post = async

>(path: P, args: pathCallArgs<`POST ${P}`>) => await fetchByPath("POST", path, args) 102 | const get = async

>(path: P, args: pathCallArgs<`GET ${P}`>) => await fetchByPath("GET", path, args) 103 | 104 | return { 105 | post, 106 | get, 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /cmd/tanukiup/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log/slog" 7 | "os" 8 | "os/signal" 9 | "strings" 10 | "syscall" 11 | 12 | "github.com/mackee/tanukirpc/tanukiup" 13 | "github.com/urfave/cli/v2" 14 | ) 15 | 16 | func main() { 17 | app := &cli.App{ 18 | Name: "tanukiup", 19 | Usage: "tanukiup is a tool to run your server and watch your files", 20 | Flags: []cli.Flag{ 21 | &cli.StringSliceFlag{ 22 | Name: "ext", 23 | Usage: "file extensions to watch", 24 | }, 25 | &cli.StringSliceFlag{ 26 | Name: "dir", 27 | Usage: "directories to watch", 28 | }, 29 | &cli.StringSliceFlag{ 30 | Name: "ignore-dir", 31 | Usage: "directories to ignore", 32 | }, 33 | &cli.StringFlag{ 34 | Name: "build", 35 | Usage: "build command. {outpath} represents the output path.", 36 | DefaultText: "go build -o {outpath} ./", 37 | }, 38 | &cli.StringFlag{ 39 | Name: "exec", 40 | Usage: "exec command. {outpath} represents the output path.", 41 | DefaultText: "{outpath}", 42 | }, 43 | &cli.StringFlag{ 44 | Name: "addr", 45 | Usage: "port number to run the server. this use for the proxy mode.", 46 | }, 47 | &cli.StringFlag{ 48 | Name: "base-dir", 49 | Usage: "base directory to watch. if not specified, the current directory is used", 50 | }, 51 | &cli.StringFlag{ 52 | Name: "temp-dir", 53 | Usage: "temporary directory to store the built binary. if not specified, the system's temp directory is used", 54 | }, 55 | &cli.StringFlag{ 56 | Name: "handler-dir", 57 | Usage: "directory to watch for the handler files. if not specified, the base directory is used", 58 | }, 59 | &cli.StringFlag{ 60 | Name: "catchall-target", 61 | Usage: "target to catch all requests. if not specified, the server returns 404", 62 | }, 63 | &cli.StringFlag{ 64 | Name: "log-level", 65 | Usage: "log level (debug, info, warn, error)", 66 | }, 67 | }, 68 | Action: run, 69 | } 70 | 71 | if err := app.Run(os.Args); err != nil { 72 | panic(err) 73 | } 74 | } 75 | 76 | func run(cctx *cli.Context) error { 77 | opts := tanukiup.Options{} 78 | if exts := cctx.StringSlice("ext"); len(exts) > 0 { 79 | opts = append(opts, tanukiup.WithFileExts(exts)) 80 | } 81 | if dirs := cctx.StringSlice("dir"); len(dirs) > 0 { 82 | opts = append(opts, tanukiup.WithDirs(dirs)) 83 | } 84 | if ignoreDirs := cctx.StringSlice("ignore-dir"); len(ignoreDirs) > 0 { 85 | opts = append(opts, tanukiup.WithIgnoreDirs(ignoreDirs)) 86 | } 87 | if port := cctx.String("addr"); port != "" { 88 | opts = append(opts, tanukiup.WithAddr(port)) 89 | } 90 | if logLevel := cctx.String("log-level"); logLevel != "" { 91 | levelMap := map[string]slog.Level{ 92 | "debug": slog.LevelDebug, 93 | "info": slog.LevelInfo, 94 | "warn": slog.LevelWarn, 95 | "error": slog.LevelError, 96 | } 97 | if lv, ok := levelMap[logLevel]; ok { 98 | opts = append(opts, tanukiup.WithLogLevel(lv)) 99 | } else { 100 | return fmt.Errorf("unknown log level: %s", logLevel) 101 | } 102 | } 103 | if build := cctx.String("build"); build != "" { 104 | bc := strings.Fields(build) 105 | opts = append(opts, tanukiup.WithBuildCommand(bc)) 106 | } 107 | if exec := cctx.String("exec"); exec != "" { 108 | ec := strings.Fields(exec) 109 | opts = append(opts, tanukiup.WithExecCommand(ec)) 110 | } 111 | if basedir := cctx.String("base-dir"); basedir != "" { 112 | opts = append(opts, tanukiup.WithBaseDir(basedir)) 113 | } 114 | if tempdir := cctx.String("temp-dir"); tempdir != "" { 115 | opts = append(opts, tanukiup.WithTempDir(tempdir)) 116 | } 117 | if catchallTarget := cctx.String("catchall-target"); catchallTarget != "" { 118 | opts = append(opts, tanukiup.WithCatchAllTarget(catchallTarget)) 119 | } 120 | if handlerDir := cctx.String("handler-dir"); handlerDir != "" { 121 | opts = append(opts, tanukiup.WithHandlerDir(handlerDir)) 122 | } 123 | 124 | ctx, cancel := context.WithCancel(cctx.Context) 125 | 126 | sig := make(chan os.Signal, 1) 127 | signal.Notify(sig, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) 128 | go func() { 129 | <-sig 130 | cancel() 131 | }() 132 | 133 | if err := tanukiup.Run(ctx, opts...); err != nil { 134 | return fmt.Errorf("failed to run tanukiup: %w", err) 135 | } 136 | return nil 137 | } 138 | -------------------------------------------------------------------------------- /_example/todo/frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { FaPlusCircle, FaBan, FaEdit, FaCheck, FaTrash } from "react-icons/fa"; 2 | import { type ReactNode, useState } from "react"; 3 | import { fetchTasks, addTask, deleteTask, updateTask } from "./tasks"; 4 | import "./App.css"; 5 | 6 | function App() { 7 | return ( 8 | <> 9 |

TODO App Demo

10 |
17 | 18 | 19 |
20 | 21 | ); 22 | } 23 | 24 | function Menu() { 25 | return ( 26 | 34 | ); 35 | } 36 | 37 | function IconButton({ 38 | onClick, 39 | children, 40 | }: { onClick?: () => void; children: ReactNode }) { 41 | return ( 42 | 53 | ); 54 | } 55 | 56 | function AddButton() { 57 | const onClickAdd = () => { 58 | addTask({ 59 | name: "New Task", 60 | description: "Description", 61 | }); 62 | }; 63 | 64 | return ( 65 | 66 | 67 | Add 68 | 69 | ); 70 | } 71 | 72 | function TaskList() { 73 | const { tasks } = fetchTasks(); 74 | 75 | if (tasks.length === 0) { 76 | return ( 77 |
83 |

93 | 94 | Empty 95 |

96 |
97 | ); 98 | } 99 | 100 | return ( 101 | <> 102 | {tasks.map((task) => ( 103 | deleteTask(task.id)} 110 | /> 111 | ))} 112 | 113 | ); 114 | } 115 | 116 | function TaskMenu({ 117 | editable, 118 | onClickEdit, 119 | onClickDelete, 120 | onClickDone, 121 | }: { 122 | editable: boolean; 123 | onClickEdit: () => void; 124 | onClickDone: () => void; 125 | onClickDelete: () => void; 126 | }) { 127 | return ( 128 | 152 | ); 153 | } 154 | 155 | function Task({ 156 | taskId, 157 | initialName, 158 | initialDescription, 159 | initialEditable, 160 | deleteTask, 161 | }: { 162 | taskId: string; 163 | initialName: string; 164 | initialDescription: string; 165 | initialEditable: boolean; 166 | deleteTask: () => void; 167 | }) { 168 | const [editable, setEditable] = useState(initialEditable); 169 | const [name, setName] = useState(initialName); 170 | const [description, setDescription] = useState(initialDescription); 171 | 172 | const onClickDone = async () => { 173 | setEditable(false); 174 | const task = await updateTask({ 175 | id: taskId, 176 | name, 177 | description, 178 | }); 179 | if (!task) return; 180 | setName(task.name); 181 | setDescription(task.description); 182 | }; 183 | 184 | return ( 185 |
195 |

Task #{taskId}

196 | setName(value)} 200 | /> 201 |

Description

202 | setDescription(value)} 206 | /> 207 | setEditable(true)} 210 | onClickDone={onClickDone} 211 | onClickDelete={() => deleteTask()} 212 | /> 213 |
214 | ); 215 | } 216 | 217 | function TaskName({ 218 | editable, 219 | name, 220 | onChange, 221 | }: { editable: boolean; name: string; onChange: (value: string) => void }) { 222 | if (editable) { 223 | return ( 224 | onChange(e.target.value)} 229 | /> 230 | ); 231 | } 232 | 233 | return

{name}

; 234 | } 235 | 236 | function TaskDescription({ 237 | editable, 238 | description, 239 | onChange, 240 | }: { 241 | editable: boolean; 242 | description: string; 243 | onChange: (value: string) => void; 244 | }) { 245 | if (editable) { 246 | return ( 247 |