A website inspired by Last.fm that will keep track of your listening statistics
lastfm music statistics
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add bridge command to generate TypeScript routes and schemas

Add router to server struct and expose it as a method

Move router creation to router package

Split bridge package into routes and schemas files

(These files are from an earlier project)

oscar345 0cd0a9db 4fc4662d

+557 -10
+116
cmd/bridge/main.go
··· 1 + package main 2 + 3 + import ( 4 + "errors" 5 + "flag" 6 + "fmt" 7 + "os" 8 + 9 + "github.com/oscar345/keeptrack/internal/web/router" 10 + "github.com/oscar345/keeptrack/pkg/bridge" 11 + ) 12 + 13 + type RouteCommand struct { 14 + flagSet *flag.FlagSet 15 + path string 16 + } 17 + 18 + func newRouteCommand() Command { 19 + command := &RouteCommand{ 20 + flagSet: flag.NewFlagSet("route", flag.ContinueOnError), 21 + } 22 + 23 + command.flagSet.StringVar(&command.path, "path", "", "The path in which the generated code will be saved") 24 + 25 + return command 26 + } 27 + 28 + func (c *RouteCommand) Init(args []string) { 29 + c.flagSet.Parse(args) 30 + } 31 + 32 + func (c *RouteCommand) Run() error { 33 + if c.path == "" { 34 + return errors.New("path is required") 35 + } 36 + 37 + router := router.Server{} 38 + bridge.CreateRoutes(router.Router(), c.path) 39 + 40 + return nil 41 + } 42 + 43 + func (c *RouteCommand) Name() string { 44 + return c.flagSet.Name() 45 + } 46 + 47 + type SchemaCommand struct { 48 + flagSet *flag.FlagSet 49 + path string 50 + packageName string 51 + } 52 + 53 + func (c *SchemaCommand) Init(args []string) { 54 + c.flagSet.Parse(args) 55 + } 56 + 57 + func (c *SchemaCommand) Run() error { 58 + if c.path == "" { 59 + return errors.New("path is required") 60 + } 61 + if c.packageName == "" { 62 + return errors.New("package name is required") 63 + } 64 + 65 + return bridge.CreateSchemas(c.packageName, c.path) 66 + } 67 + 68 + func (c *SchemaCommand) Name() string { 69 + return c.flagSet.Name() 70 + } 71 + 72 + func newSchemaCommand() Command { 73 + command := &SchemaCommand{ 74 + flagSet: flag.NewFlagSet("schema", flag.ContinueOnError), 75 + } 76 + 77 + command.flagSet.StringVar(&command.path, "path", "", "The path in which the generated code will be saved") 78 + command.flagSet.StringVar(&command.packageName, "package", "", "The package name of the code where the models are currently stored") 79 + 80 + return command 81 + } 82 + 83 + type Command interface { 84 + Init(args []string) 85 + Run() error 86 + Name() string 87 + } 88 + 89 + func root(args []string) error { 90 + if len(args) < 1 { 91 + return errors.New("You must pass a sub-command") 92 + } 93 + 94 + cmds := []Command{ 95 + newRouteCommand(), 96 + newSchemaCommand(), 97 + } 98 + 99 + subcommand := os.Args[1] 100 + 101 + for _, cmd := range cmds { 102 + if cmd.Name() == subcommand { 103 + cmd.Init(os.Args[2:]) 104 + return cmd.Run() 105 + } 106 + } 107 + 108 + return fmt.Errorf("Unknown subcommand: %s", subcommand) 109 + } 110 + 111 + func main() { 112 + if err := root(os.Args[1:]); err != nil { 113 + fmt.Println(err) 114 + os.Exit(1) 115 + } 116 + }
+8 -4
internal/server/server.go
··· 41 41 }) 42 42 defer statisticsDB.Close() 43 43 44 - server := http.Server{ 45 - Addr: s.address, 46 - Handler: router.New( 44 + router := router. 45 + New( 47 46 db.NewArtistRepoDB(musicbrainzDB), 48 47 db.NewArtistScrobbleRepoDB(statisticsDB), 49 48 image.NewArtistImageFetcherFanArtTV(s.config.Services.FanartTV.APIKey), 50 49 db.NewRecordingRepoDB(musicbrainzDB), 51 50 storage.NewDiskStorage("/public", s.config.Storage.Disk.Path), 52 51 s.config, 53 - ), 52 + ). 53 + Router() 54 + 55 + server := http.Server{ 56 + Addr: s.address, 57 + Handler: router, 54 58 } 55 59 log.Printf("Listening on http://%s\n", s.address) 56 60
+6 -5
internal/web/router/router.go
··· 1 1 package router 2 2 3 3 import ( 4 - "net/http" 5 - 6 4 "github.com/ggicci/httpin" 7 5 "github.com/go-chi/chi/v5" 8 6 "github.com/go-chi/chi/v5/middleware" ··· 16 14 ) 17 15 18 16 type Server struct { 17 + chi *chi.Mux 19 18 artistService services.ArtistService 20 19 config *config.Config 21 20 } ··· 27 26 recordingRepo repo.RecordingRepo, 28 27 storage storagesvc.Storage, 29 28 config *config.Config, 30 - ) http.Handler { 31 - server := &Server{ 29 + ) *Server { 30 + return &Server{ 32 31 artistService: services.NewArtistService( 33 32 artistRepo, artistScrobbleRepo, artistImageFetcher, storage, 34 33 ), 35 34 config: config, 36 35 } 36 + } 37 37 38 + func (s *Server) Router() *chi.Mux { 38 39 r := chi.NewRouter() 39 40 40 41 r.Use( ··· 43 44 middleware.CleanPath, 44 45 ) 45 46 46 - r.Group(server.index()) 47 + r.Group(s.index()) 47 48 48 49 return r 49 50 }
-1
pkg/bridge/bridge.go
··· 1 - package bridge
+152
pkg/bridge/routes.go
··· 1 + package bridge 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "log" 7 + "net/http" 8 + "os" 9 + "path/filepath" 10 + "regexp" 11 + "strings" 12 + "text/template" 13 + "unicode" 14 + 15 + "github.com/go-chi/chi/v5" 16 + "github.com/oscar345/keeptrack/pkg/enum" 17 + ) 18 + 19 + func capitalizeFirstLetter(s string) string { 20 + if len(s) == 0 { 21 + return s 22 + } 23 + if s == "id" { 24 + return "ID" 25 + } 26 + runes := []rune(s) 27 + runes[0] = unicode.ToUpper(runes[0]) 28 + return string(runes) 29 + } 30 + 31 + func createFunctionName(method, route string) string { 32 + if route == "/" { 33 + route = "index" 34 + } 35 + 36 + items := enum.Map(strings.Split(route, "/"), func(item string) string { 37 + if strings.HasPrefix(item, "{") && strings.HasSuffix(item, "}") { 38 + name := item[1 : len(item)-1] 39 + name = "By" + capitalizeFirstLetter(name) 40 + return name 41 + } 42 + 43 + return capitalizeFirstLetter(item) 44 + }) 45 + items = enum.Filter(items, func(s string) bool { 46 + return s != "" 47 + }) 48 + route = strings.Join(items, "") 49 + 50 + return method + "_" + route 51 + } 52 + 53 + func getFunctionParams(route string) string { 54 + items := enum.Filter(strings.Split(route, "/"), func(s string) bool { 55 + return strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") 56 + }) 57 + 58 + items = enum.Map(items, func(item string) string { 59 + name := item[1 : len(item)-1] 60 + return fmt.Sprintf("%s: string | number", name) 61 + }) 62 + 63 + return strings.Join(items, ", ") 64 + } 65 + 66 + func createTypescriptFunc(writer io.Writer, method, route string) error { 67 + fun := ` 68 + export function {{ .name }}({{ .params }}) : { url: string, method: Method} { 69 + return { 70 + url: '''{{ .route }}''', 71 + method: "{{ .method }}" 72 + } 73 + } 74 + 75 + ` 76 + fun = strings.ReplaceAll(fun, "'''", "`") 77 + 78 + templ, err := template.New("funcs").Parse(fun) 79 + 80 + if err != nil { 81 + return err 82 + } 83 + 84 + name := createFunctionName(method, route) 85 + params := getFunctionParams(route) 86 + 87 + re, err := regexp.Compile(`\{([^}]+)\}`) 88 + if err != nil { 89 + return err 90 + } 91 + route = re.ReplaceAllString(route, `${$1}`) 92 + 93 + return templ.Execute(writer, map[string]string{ 94 + "name": name, 95 + "method": strings.ToLower(method), 96 + "route": route, 97 + "params": params, 98 + }) 99 + } 100 + 101 + func createFilePrefix(writer io.Writer) error { 102 + fun := ` 103 + import type { Method } from "$lib/types"; 104 + ` 105 + 106 + templ, err := template.New("prefix").Parse(fun) 107 + 108 + if err != nil { 109 + return err 110 + } 111 + 112 + return templ.Execute(writer, map[string]string{}) 113 + } 114 + 115 + func CreateRoutes(router *chi.Mux, path string) error { 116 + path, err := filepath.Abs(path) 117 + if err != nil { 118 + return err 119 + } 120 + 121 + if err := os.MkdirAll(filepath.Dir(path), 0777); err != nil { 122 + return err 123 + } 124 + 125 + if _, err := os.Stat(path); err == nil { 126 + err := os.Remove(path) 127 + if err != nil { 128 + return err 129 + } 130 + } 131 + 132 + file, err := os.Create(path) 133 + if err != nil { 134 + return err 135 + } 136 + defer file.Close() 137 + 138 + if err := createFilePrefix(file); err != nil { 139 + return err 140 + } 141 + 142 + return chi.Walk(router, func( 143 + method, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler, 144 + ) error { 145 + if strings.Contains(route, "*") { 146 + return nil 147 + } 148 + result := createTypescriptFunc(file, method, route) 149 + log.Println(result) 150 + return result 151 + }) 152 + }
+275
pkg/bridge/schemas.go
··· 1 + package bridge 2 + 3 + import ( 4 + "errors" 5 + "fmt" 6 + "go/ast" 7 + "go/token" 8 + "io" 9 + "log" 10 + "os" 11 + "path/filepath" 12 + "reflect" 13 + "slices" 14 + "strings" 15 + "text/template" 16 + 17 + "golang.org/x/tools/go/packages" 18 + ) 19 + 20 + func getPackage(name string) (*packages.Package, error) { 21 + config := &packages.Config{ 22 + Mode: packages.NeedSyntax | packages.NeedTypes | packages.NeedTypesInfo, 23 + Fset: token.NewFileSet(), 24 + } 25 + 26 + pkg, err := packages.Load(config, name) 27 + if err != nil { 28 + return nil, err 29 + } 30 + if len(pkg) == 0 { 31 + return nil, errors.New("No packages found") 32 + } 33 + 34 + return pkg[0], nil 35 + } 36 + 37 + func getStructs(pkg *packages.Package) map[string]*ast.TypeSpec { 38 + structs := make(map[string]*ast.TypeSpec) 39 + 40 + for _, file := range pkg.Syntax { 41 + ast.Inspect(file, func(node ast.Node) bool { 42 + typespec, ok := node.(*ast.TypeSpec) 43 + if !ok { 44 + return true 45 + } 46 + 47 + if _, ok := typespec.Type.(*ast.StructType); ok { 48 + structs[typespec.Name.Name] = typespec 49 + } 50 + 51 + return true 52 + }) 53 + } 54 + 55 + return structs 56 + } 57 + 58 + func createFile(path string) (*os.File, error) { 59 + path, err := filepath.Abs(path) 60 + if err != nil { 61 + return nil, err 62 + } 63 + 64 + if err := os.MkdirAll(filepath.Dir(path), 0777); err != nil { 65 + return nil, err 66 + } 67 + 68 + if _, err := os.Stat(path); err == nil { 69 + err := os.Remove(path) 70 + if err != nil { 71 + return nil, err 72 + } 73 + } 74 + 75 + return os.Create(path) 76 + } 77 + 78 + func CreateSchemas(packageName string, path string) error { 79 + pkg, err := getPackage(packageName) 80 + if err != nil { 81 + return err 82 + } 83 + 84 + structs := getStructs(pkg) 85 + 86 + file, err := createFile(path) 87 + if err != nil { 88 + return err 89 + } 90 + defer file.Close() 91 + 92 + for name, typespec := range structs { 93 + if err := writeTypescriptType(file, name, typespec); err != nil { 94 + return err 95 + } 96 + } 97 + 98 + return nil 99 + } 100 + 101 + func writeTypescriptType(writer io.Writer, name string, typespec *ast.TypeSpec) error { 102 + struct_ := typespec.Type.(*ast.StructType) 103 + generics := getGenerics(typespec) 104 + fields, err := getFields(struct_) 105 + 106 + if err != nil { 107 + return err 108 + } 109 + 110 + const typescript = ` 111 + export type {{ .name }}{{ if .generics }}<{{ range $index, $value := .generics }}{{ if ne $index 0 }}, {{ end }}{{ $value }}{{ end }}> {{ end }} = { 112 + {{ range .fields }} 113 + {{ .Name }}: {{ .Type }}{{ if .Optional }} | null{{ end }}; 114 + {{ end }} 115 + }` 116 + 117 + template, err := template.New("typescript").Parse(typescript) 118 + if err != nil { 119 + return err 120 + } 121 + 122 + return template.Execute(writer, map[string]any{ 123 + "name": name, 124 + "fields": fields, 125 + "generics": generics, 126 + }) 127 + } 128 + 129 + func getGenerics(typespec *ast.TypeSpec) []string { 130 + if typespec.TypeParams == nil { 131 + return nil 132 + } 133 + 134 + params := []string{} 135 + for _, field := range typespec.TypeParams.List { 136 + for _, name := range field.Names { 137 + params = append(params, name.Name) 138 + } 139 + } 140 + 141 + return params 142 + } 143 + 144 + type Field struct { 145 + Name string 146 + Type string 147 + Optional bool 148 + } 149 + 150 + func getJSONTag(field *ast.Field) (string, error) { 151 + var errorNoValue = fmt.Errorf("field in struct does not have a value in json tag") 152 + 153 + if field.Tag == nil { 154 + return "", errorNoValue 155 + } 156 + 157 + raw := strings.Trim(field.Tag.Value, "`") 158 + tag := reflect.StructTag(raw) 159 + value := tag.Get("json") 160 + 161 + if value == "" { 162 + return "", errorNoValue 163 + } 164 + 165 + return value, nil 166 + } 167 + 168 + func parseJSONTag(tag string) (string, bool) { 169 + parts := strings.Split(tag, ",") 170 + return parts[0], slices.Contains(parts, "omitempty") 171 + } 172 + 173 + func getFields(struct_ *ast.StructType) ([]Field, error) { 174 + fields := make([]Field, 0) 175 + 176 + for _, field := range struct_.Fields.List { 177 + if len(field.Names) == 0 { 178 + if embedded, ok := field.Type.(*ast.StructType); ok { 179 + inline, err := getFields(embedded) 180 + if err != nil { 181 + return nil, err 182 + } 183 + fields = append(fields, inline...) 184 + } 185 + continue 186 + } 187 + 188 + value, err := getJSONTag(field) 189 + if err != nil { 190 + return fields, err 191 + } 192 + name, optional := parseJSONTag(value) 193 + 194 + type_, err := getTypescriptType(field.Type) 195 + if err != nil { 196 + return fields, err 197 + } 198 + 199 + fields = append(fields, Field{ 200 + Name: name, 201 + Optional: optional, 202 + Type: type_, 203 + }) 204 + } 205 + 206 + return fields, nil 207 + } 208 + 209 + func getTypescriptType(expr ast.Expr) (string, error) { 210 + switch t := expr.(type) { 211 + case *ast.Ident: 212 + return getTypescriptTypeBasis(t.Name), nil 213 + case *ast.StarExpr: 214 + return getTypescriptType(t.X) 215 + case *ast.ArrayType: 216 + value, err := getTypescriptType(t.Elt) 217 + if err != nil { 218 + return "", err 219 + } 220 + return value + "[]", nil 221 + case *ast.MapType: 222 + key, err := getTypescriptType(t.Key) 223 + if err != nil { 224 + return "", err 225 + } 226 + value, err := getTypescriptType(t.Value) 227 + if err != nil { 228 + return "", err 229 + } 230 + return fmt.Sprintf("Record<%s, %s>", key, value), nil 231 + case *ast.StructType: 232 + fields, err := getFields(t) 233 + if err != nil { 234 + log.Panicln(err) 235 + } 236 + return createTypescriptInlineObject(fields) 237 + } 238 + 239 + return "any", nil 240 + } 241 + 242 + func getTypescriptTypeBasis(name string) string { 243 + switch name { 244 + case "string": 245 + return "string" 246 + case "int", "int8", "int16", "int32", "int64", 247 + "uint", "uint16", "uint32", "uint64", 248 + "float32", "float64": 249 + return "number" 250 + case "bool": 251 + return "boolean" 252 + default: 253 + return name 254 + } 255 + } 256 + 257 + func createTypescriptInlineObject(fields []Field) (string, error) { 258 + const typescript = `{ 259 + {{ range .fields }} 260 + {{ .name }}{{ if .optional }}?{{ end }}: {{ .type }}; 261 + {{ end }} 262 + }` 263 + 264 + templ, err := template.New("typescript").Parse(typescript) 265 + if err != nil { 266 + return "", err 267 + } 268 + 269 + var buf strings.Builder 270 + err = templ.Execute(&buf, struct{ fields []Field }{fields}) 271 + if err != nil { 272 + return "", err 273 + } 274 + return buf.String(), nil 275 + }