diff --git a/favicon/about.txt b/favicon/about.txt new file mode 100644 index 0000000..1b6e6a6 --- /dev/null +++ b/favicon/about.txt @@ -0,0 +1,6 @@ +This favicon was generated using the following graphics from Twitter Twemoji: + +- Graphics Title: 2699.svg +- Graphics Author: Copyright 2020 Twitter, Inc and other contributors (https://github.com/twitter/twemoji) +- Graphics Source: https://github.com/twitter/twemoji/blob/master/assets/svg/2699.svg +- Graphics License: CC-BY 4.0 (https://creativecommons.org/licenses/by/4.0/) diff --git a/favicon/android-chrome-192x192.png b/favicon/android-chrome-192x192.png new file mode 100644 index 0000000..70dd800 Binary files /dev/null and b/favicon/android-chrome-192x192.png differ diff --git a/favicon/android-chrome-512x512.png b/favicon/android-chrome-512x512.png new file mode 100644 index 0000000..e3faae0 Binary files /dev/null and b/favicon/android-chrome-512x512.png differ diff --git a/favicon/apple-touch-icon.png b/favicon/apple-touch-icon.png new file mode 100644 index 0000000..cf75fab Binary files /dev/null and b/favicon/apple-touch-icon.png differ diff --git a/favicon/favicon-16x16.png b/favicon/favicon-16x16.png new file mode 100644 index 0000000..a27391f Binary files /dev/null and b/favicon/favicon-16x16.png differ diff --git a/favicon/favicon-32x32.png b/favicon/favicon-32x32.png new file mode 100644 index 0000000..a6368fe Binary files /dev/null and b/favicon/favicon-32x32.png differ diff --git a/favicon/favicon.ico b/favicon/favicon.ico new file mode 100644 index 0000000..c5369b7 Binary files /dev/null and b/favicon/favicon.ico differ diff --git a/favicon/site.webmanifest b/favicon/site.webmanifest new file mode 100644 index 0000000..45dc8a2 --- /dev/null +++ b/favicon/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/internal/consts/logging.go b/internal/consts/logging.go new file mode 100644 index 0000000..622da8c --- /dev/null +++ b/internal/consts/logging.go @@ -0,0 +1,7 @@ +package consts + +import "log/slog" + +const ( + LevelTrace slog.Level = slog.LevelDebug - 4 +) diff --git a/internal/domains/authentication/auth.go b/internal/domains/authentication/auth.go index cda1995..646f6cd 100644 --- a/internal/domains/authentication/auth.go +++ b/internal/domains/authentication/auth.go @@ -31,7 +31,7 @@ const ( AUTH0_CLIENT_SECRET = "83U-iWdVaNnwk9XDzteo_2VMyOq_l1siKYqg1_2E7jCzgL8MnkaxlysPMcPMGlxA" // The Callback URL of our application. - AUTH0_CALLBACK_URL = "https://inventory-plus-plus.com/login/callback" + AUTH0_CALLBACK_URL = "https://inventory-plus-plus.com/auth/login/callback" ) type ( diff --git a/internal/server/accounts.go b/internal/server/api/accounts/router.go similarity index 64% rename from internal/server/accounts.go rename to internal/server/api/accounts/router.go index 5a2ac5a..7867054 100644 --- a/internal/server/accounts.go +++ b/internal/server/api/accounts/router.go @@ -1,19 +1,52 @@ -package server +package accounts import ( "errors" "fmt" + "log/slog" "net/http" - "strconv" - "ruben/inventory2/internal/consts" "ruben/inventory2/internal/domains/accounts" "ruben/inventory2/internal/server/middleware" "ruben/inventory2/internal/server/response" + "ruben/inventory2/internal/server/router" + "strconv" ) +type accountSubrouter struct { + log *slog.Logger + *router.SubMux + accts *accounts.Store +} + +func NewAccountSubrouter( + logger *slog.Logger, + accts *accounts.Store, + authMiddleware *middleware.Auth, +) *accountSubrouter { + mux := router.NewSubMux(logger) + + as := &accountSubrouter{ + log: logger, + SubMux: mux, + accts: accts, + } + + withAuth := func(fn response.HandlerFunc) response.HandlerFunc { + return authMiddleware.AuthenticateAndAddIdentity(fn) + } + + mux.Handle("POST /{acctID}/inventory/sync-groups/draft/listings", withAuth(as.createSyncGroupListingDraft)) + mux.Handle("PUT /{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop", withAuth(as.setShopInSyncGroupListingDraft)) + mux.Handle("PUT /{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing", withAuth(as.setListingInSyncGroupListingDraft)) + mux.Handle("DELETE /{acctID}/inventory/sync-groups/draft/listings/{orderIndex}", withAuth(as.deleteSyncGroupListingDraft)) + mux.Handle("POST /{acctID}/inventory/sync-groups", withAuth(as.saveNewSyncGroup)) + + return as +} + // POST /accounts -func (s *Server) createAccount(r *http.Request) (response.Response, error) { +func (s *accountSubrouter) createAccount(r *http.Request) (response.Response, error) { ctx := r.Context() email := r.FormValue("email") if email == "" { @@ -35,7 +68,7 @@ func (s *Server) createAccount(r *http.Request) (response.Response, error) { } // POST /accounts/{acctID}/inventory/sync-groups/draft/listings -func (s *Server) createSyncGroupListingDraft(r *http.Request) (response.Response, error) { +func (s *accountSubrouter) createSyncGroupListingDraft(r *http.Request) (response.Response, error) { ctx := r.Context() acctID := middleware.GetIdentity(ctx).Account.AccountID @@ -53,7 +86,7 @@ func (s *Server) createSyncGroupListingDraft(r *http.Request) (response.Response // PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop // @platform string // @shopID string -func (s *Server) setShopInSyncGroupListingDraft(r *http.Request) (response.Response, error) { +func (s *accountSubrouter) setShopInSyncGroupListingDraft(r *http.Request) (response.Response, error) { ctx := r.Context() acctID := middleware.GetIdentity(ctx).Account.AccountID @@ -76,7 +109,7 @@ func (s *Server) setShopInSyncGroupListingDraft(r *http.Request) (response.Respo } if err := s.accts.SetShopInSyncGroupListingDraft(ctx, acctID, orderIndex, platform, shopID); err != nil { - return nil, response.Errorf("failed to set shop: %w", mapConstantErrorsToHTTPErrors(err)) + return nil, response.Errorf("failed to set shop: %w", response.ErrorFromConstant(err)) } return response.Redirect( @@ -86,7 +119,7 @@ func (s *Server) setShopInSyncGroupListingDraft(r *http.Request) (response.Respo } // PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing -func (s *Server) setListingInSyncGroupListingDraft(r *http.Request) (response.Response, error) { +func (s *accountSubrouter) setListingInSyncGroupListingDraft(r *http.Request) (response.Response, error) { ctx := r.Context() acctID := middleware.GetIdentity(ctx).Account.AccountID @@ -102,7 +135,7 @@ func (s *Server) setListingInSyncGroupListingDraft(r *http.Request) (response.Re } if err := s.accts.SetListingInSyncGroupListingDraft(ctx, acctID, orderIndex, listingID); err != nil { - return nil, response.Errorf("failed to set listing: %w", mapConstantErrorsToHTTPErrors(err)) + return nil, response.Errorf("failed to set listing: %w", response.ErrorFromConstant(err)) } return response.Redirect( @@ -112,7 +145,7 @@ func (s *Server) setListingInSyncGroupListingDraft(r *http.Request) (response.Re } // DELETE /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex} -func (s *Server) deleteSyncGroupListingDraft(r *http.Request) (response.Response, error) { +func (s *accountSubrouter) deleteSyncGroupListingDraft(r *http.Request) (response.Response, error) { ctx := r.Context() acctID := middleware.GetIdentity(ctx).Account.AccountID @@ -122,20 +155,20 @@ func (s *Server) deleteSyncGroupListingDraft(r *http.Request) (response.Response } if _, err := s.accts.DeleteSyncGroupListingDraft(ctx, acctID, orderIndex); err != nil { - return nil, response.Errorf("failed to delete listing: %w", mapConstantErrorsToHTTPErrors(err)) + return nil, response.Errorf("failed to delete listing: %w", response.ErrorFromConstant(err)) } return response.Status(200), nil } // POST /accounts/{acctID}/inventory/sync-groups -func (s *Server) saveNewSyncGroup(r *http.Request) (response.Response, error) { +func (s *accountSubrouter) saveNewSyncGroup(r *http.Request) (response.Response, error) { ctx := r.Context() acctID := middleware.GetIdentity(ctx).Account.AccountID grp, err := s.accts.SaveNewSyncGroup(ctx, acctID) if err != nil { - return nil, response.Errorf("failed to save new sync group: %w", mapConstantErrorsToHTTPErrors(err)) + return nil, response.Errorf("failed to save new sync group: %w", response.ErrorFromConstant(err)) } return response.Redirect( @@ -147,10 +180,15 @@ func (s *Server) saveNewSyncGroup(r *http.Request) (response.Response, error) { func getOrderIndexForSyncGroupListingDraftFromPath(r *http.Request) (int, error) { orderIndexStr := r.PathValue("orderIndex") + if orderIndexStr == "" { + return 0, response.NotFound(). + Msg("no orderIndex found") + } + orderIndex, err := strconv.Atoi(orderIndexStr) if err != nil { return 0, response.NotFound(). - Msgf("no listing draft found at %s", orderIndexStr) + Msgf("invalid order index: %s", orderIndexStr) } return orderIndex, nil diff --git a/internal/server/login.go b/internal/server/api/auth/router.go similarity index 54% rename from internal/server/login.go rename to internal/server/api/auth/router.go index 2dada0d..8bf5546 100644 --- a/internal/server/login.go +++ b/internal/server/api/auth/router.go @@ -1,21 +1,58 @@ -package server +package auth import ( "context" "fmt" + "log/slog" "net/http" "ruben/inventory2/internal/domains/authentication" "ruben/inventory2/internal/server/cookies" "ruben/inventory2/internal/server/response" + "ruben/inventory2/internal/server/router" ) -// TODO: use a login/logout server? (prefix: '/auth'?) +type loginSubrouter struct { + log *slog.Logger + auth *authentication.Authenticator + router.Subrouter +} -// GET /login -func (s *Server) loginPage(r *http.Request) (response.Response, error) { +func NewLoginSubrouter(logger *slog.Logger, auth *authentication.Authenticator) *loginSubrouter { + mux := router.NewSubMux(logger) + + ls := &loginSubrouter{ + log: logger, + auth: auth, + Subrouter: mux, + } + + mux.Handle("GET /login", ls.loginPage) + mux.Handle("GET /login/callback", ls.loginCallback) + mux.Handle("GET /logout", ls.logoutPage) + + return ls +} + +func (s *loginSubrouter) newLoginSubrouter(logger *slog.Logger) router.Subrouter { + mux := router.NewSubMux(logger) + + ls := &loginSubrouter{ + log: s.log, + auth: s.auth, + Subrouter: mux, + } + + mux.Handle("GET /login", ls.loginPage) + mux.Handle("GET /login/callback", ls.loginCallback) + mux.Handle("GET /logout", ls.logoutPage) + + return ls +} + +func (s *loginSubrouter) loginPage(r *http.Request) (response.Response, error) { ctx := r.Context() - u, err := newLoginURL(ctx, s.auth, "/") + u, err := NewLoginURL(ctx, s.auth, "/") if err != nil { return nil, err } @@ -23,7 +60,7 @@ func (s *Server) loginPage(r *http.Request) (response.Response, error) { return response.TemporaryRedirect(u), nil } -func newLoginURL(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error) { +func NewLoginURL(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error) { state, err := auth.NewState(ctx, targetURI) if err != nil { return "", fmt.Errorf("failed to generate random state: %w", err) @@ -34,8 +71,7 @@ func newLoginURL(ctx context.Context, auth *authentication.Authenticator, target return auth.AuthCodeURL(base64EncodedState), nil } -// GET /login/callback -func (s *Server) loginCallback(r *http.Request) (response.Response, error) { +func (s *loginSubrouter) loginCallback(r *http.Request) (response.Response, error) { ctx := r.Context() q := r.URL.Query() @@ -54,8 +90,7 @@ func (s *Server) loginCallback(r *http.Request) (response.Response, error) { Cookie(cookies.AccessToken(accessToken, expiration)), nil } -// GET /logout -func (s *Server) logoutPage(r *http.Request) (response.Response, error) { +func (s *loginSubrouter) logoutPage(r *http.Request) (response.Response, error) { host := r.Header.Get("X-Forwarded-Host") if host == "" { host = r.Host diff --git a/internal/server/templates.go b/internal/server/api/templates/router.go similarity index 75% rename from internal/server/templates.go rename to internal/server/api/templates/router.go index 8d91e1e..5434210 100644 --- a/internal/server/templates.go +++ b/internal/server/api/templates/router.go @@ -1,8 +1,9 @@ -package server +package templates import ( "errors" "fmt" + "log/slog" "net/http" "net/url" "os" @@ -11,13 +12,62 @@ import ( "strconv" "strings" + "ruben/inventory2/internal/domains/accounts" + etsy_platform "ruben/inventory2/internal/domains/platforms/etsy" + "ruben/inventory2/internal/domains/raw_events" "ruben/inventory2/internal/server/middleware" "ruben/inventory2/internal/server/response" + "ruben/inventory2/internal/server/router" + + "github.com/angelbeltran/templater" ) +type webpageRouter struct { + log *slog.Logger + contentDir string + templater *templater.Templater + rawEvents *raw_events.Store + accts *accounts.Store + etsy *etsy_platform.Platform + router.Subrouter +} + +func NewWebpageRouter( + logger *slog.Logger, + contentDir string, + tmpl *templater.Templater, + rawEvents *raw_events.Store, + accts *accounts.Store, + etsy *etsy_platform.Platform, + authMiddleware *middleware.Auth, +) *webpageRouter { + mux := router.NewSubMux(logger) + + wr := &webpageRouter{ + log: logger, + contentDir: contentDir, + templater: tmpl, + rawEvents: rawEvents, + accts: accts, + etsy: etsy, + Subrouter: mux, + } + + // non-authenticated + mux.Handle("GET /{$}", authMiddleware.AddIdentity(wr.serveTemplates)) + + // authenticated + mux.Handle("GET /", authMiddleware.AuthenticateAndAddIdentity(wr.serveTemplates)) + + return wr +} + // GET / // compiles the page template or component template matching the url -func (s *Server) serveTemplates(r *http.Request) (response.Response, error) { +// func (s *Server) serveTemplates(r *http.Request) (response.Response, error) { +func (s *webpageRouter) serveTemplates(r *http.Request) (response.Response, error) { + fmt.Println("webpageRouter.serveTemplates:", r.URL) + name, args := s.getTemplateNameAndArgs(r, s.contentDir+"/templates/component_bodies") b, err := s.templater.ExecuteComponentBody(name, args...) @@ -38,7 +88,9 @@ func (s *Server) serveTemplates(r *http.Request) (response.Response, error) { return s.handleTemplateError(err, args...) } -func (s *Server) getTemplateNameAndArgs(r *http.Request, templateDir string) (name string, args []any) { +// func (s *Server) getTemplateNameAndArgs(r *http.Request, templateDir string) (name string, args []any) { +func (s *webpageRouter) getTemplateNameAndArgs(r *http.Request, templateDir string) (name string, args []any) { + fmt.Println("webpageRouter.getTemplateNameAndArgs:", r.URL) ctx := r.Context() name, pathParams := getTemplateNameForURL(r.URL, templateDir) @@ -137,7 +189,8 @@ func getMatchingGlobPatternsCapturingFilepathIncludingParametrizedFilepaths(file } } -func (s *Server) handleTemplateError(err error, templateArgs ...any) (response.Response, error) { +// func (s *Server) handleTemplateError(err error, templateArgs ...any) (response.Response, error) { +func (s *webpageRouter) handleTemplateError(err error, templateArgs ...any) (response.Response, error) { if isFileNotFoundError(err) { return nil, response.NotFound(). Wrap(err). diff --git a/internal/server/middleware/log.go b/internal/server/middleware/log.go index 87d2637..e60a2f5 100644 --- a/internal/server/middleware/log.go +++ b/internal/server/middleware/log.go @@ -19,6 +19,7 @@ func LogRequests(ctx context.Context, logger *slog.Logger) response.Middleware { args := []any{ "id", <-reqIDCh, "url", r.URL, + "method", r.Method, } logger.Debug("Request", args...) diff --git a/internal/server/response/body.go b/internal/server/response/body.go index 32f0d21..e1febff 100644 --- a/internal/server/response/body.go +++ b/internal/server/response/body.go @@ -67,12 +67,12 @@ func (b bodyRes) GetStatus() (int, bool) { return b.res.GetStatus() } -func (b bodyRes) getRedirect() (code redirect.Code, to string, ok bool) { +func (b bodyRes) GetRedirect() (code redirect.Code, to string, ok bool) { if b.res == nil { return 0, "", false } - return b.res.getRedirect() + return b.res.GetRedirect() } func (b bodyRes) getBody() (body io.ReadCloser, ok bool, err error) { diff --git a/internal/server/response/cookie.go b/internal/server/response/cookie.go index c6d640c..1e6ee13 100644 --- a/internal/server/response/cookie.go +++ b/internal/server/response/cookie.go @@ -66,12 +66,12 @@ func (c cookieRes) GetStatus() (int, bool) { return c.res.GetStatus() } -func (c cookieRes) getRedirect() (code redirect.Code, to string, ok bool) { +func (c cookieRes) GetRedirect() (code redirect.Code, to string, ok bool) { if c.res == nil { return 0, "", false } - return c.res.getRedirect() + return c.res.GetRedirect() } func (c cookieRes) getBody() (body io.ReadCloser, ok bool, err error) { diff --git a/internal/server/response/error.go b/internal/server/response/error.go index d03d950..166b7e3 100644 --- a/internal/server/response/error.go +++ b/internal/server/response/error.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "ruben/inventory2/internal/consts" "strings" ) @@ -133,3 +134,27 @@ func GetError(err error) (e ErrorResponse, ok bool) { ok = errors.As(err, &e) return e, ok } + +// error wrapping utilities + +func ErrorFromConstant(err error) error { + cerr := err + for cerr != nil { + switch cerr { + case consts.ErrNotFound: + return NotFound() + case consts.ErrConflict: + return Conflict() + } + + uerr, ok := cerr.(interface { + Unwrap() error + }) + if !ok { + return err + } + + cerr = uerr.Unwrap() + } + return err +} diff --git a/internal/server/response/html.go b/internal/server/response/html.go index b063390..e5f4951 100644 --- a/internal/server/response/html.go +++ b/internal/server/response/html.go @@ -68,12 +68,12 @@ func (h htmlRes) GetStatus() (int, bool) { return h.res.GetStatus() } -func (h htmlRes) getRedirect() (code redirect.Code, to string, ok bool) { +func (h htmlRes) GetRedirect() (code redirect.Code, to string, ok bool) { if h.res == nil { return 0, "", false } - return h.res.getRedirect() + return h.res.GetRedirect() } func (h htmlRes) getBody() (body io.ReadCloser, ok bool, err error) { diff --git a/internal/server/response/json.go b/internal/server/response/json.go index b2d3223..0e823cc 100644 --- a/internal/server/response/json.go +++ b/internal/server/response/json.go @@ -69,12 +69,12 @@ func (j jsonRes) GetStatus() (int, bool) { return j.res.GetStatus() } -func (j jsonRes) getRedirect() (code redirect.Code, to string, ok bool) { +func (j jsonRes) GetRedirect() (code redirect.Code, to string, ok bool) { if j.res == nil { return 0, "", false } - return j.res.getRedirect() + return j.res.GetRedirect() } func (j jsonRes) getBody() (body io.ReadCloser, ok bool, err error) { diff --git a/internal/server/response/redirect.go b/internal/server/response/redirect.go index 1ff2d2c..3479db6 100644 --- a/internal/server/response/redirect.go +++ b/internal/server/response/redirect.go @@ -107,7 +107,7 @@ func (r redirectRes) GetStatus() (int, bool) { return r.res.GetStatus() } -func (r redirectRes) getRedirect() (code redirect.Code, to string, ok bool) { +func (r redirectRes) GetRedirect() (code redirect.Code, to string, ok bool) { return r.code, r.to, true } diff --git a/internal/server/response/response.go b/internal/server/response/response.go index 858972c..7b5de48 100644 --- a/internal/server/response/response.go +++ b/internal/server/response/response.go @@ -18,7 +18,7 @@ type ( GetStatus() (code int, ok bool) getBody() (body io.ReadCloser, ok bool, err error) - getRedirect() (code redirect.Code, to string, ok bool) + GetRedirect() (code redirect.Code, to string, ok bool) getCookies() []http.Cookie getContentType() (contentType string, ok bool) diff --git a/internal/server/response/status.go b/internal/server/response/status.go index f4dd685..3cc92ef 100644 --- a/internal/server/response/status.go +++ b/internal/server/response/status.go @@ -64,12 +64,12 @@ func (s statusRes) GetStatus() (int, bool) { return s.code, true } -func (s statusRes) getRedirect() (code redirect.Code, to string, ok bool) { +func (s statusRes) GetRedirect() (code redirect.Code, to string, ok bool) { if s.res == nil { return 0, "", false } - return s.res.getRedirect() + return s.res.GetRedirect() } func (s statusRes) getBody() (body io.ReadCloser, ok bool, err error) { diff --git a/internal/server/response/write.go b/internal/server/response/write.go index 5688a97..b03de81 100644 --- a/internal/server/response/write.go +++ b/internal/server/response/write.go @@ -18,7 +18,7 @@ func Write(w http.ResponseWriter, r *http.Request, res Response) { hdrs.Add("Content-Type", ct) } - if code, to, ok := res.getRedirect(); ok { + if code, to, ok := res.GetRedirect(); ok { http.Redirect(w, r, to, code.Int()) return } diff --git a/internal/server/router/mux.go b/internal/server/router/mux.go index e8a4569..a65d3ed 100644 --- a/internal/server/router/mux.go +++ b/internal/server/router/mux.go @@ -1,8 +1,14 @@ package router import ( + "fmt" + "log/slog" "net/http" + "path" + "slices" + "strings" + "ruben/inventory2/internal/consts" "ruben/inventory2/internal/server/response" ) @@ -10,27 +16,642 @@ type ( Mux struct { Mux *http.ServeMux middleware []response.Middleware + log *slog.Logger } ) -func NewMux(ms ...response.Middleware) *Mux { +var ( + ErrHandlerNotFound = fmt.Errorf("%w: handler not found", consts.ErrNotFound) +) + +func NewMux(log *slog.Logger, ms ...response.Middleware) *Mux { return &Mux{ Mux: http.NewServeMux(), middleware: ms, + log: log, } } +func (m *Mux) AddMiddleware(ms ...response.Middleware) *Mux { + m.middleware = append(m.middleware, ms...) + return m +} + +func (m *Mux) traced(method, msg string, args ...any) func(func() (finalArgs []any)) { + return logTraceAndDefer(m.log, "Mux", method, msg, args...) +} + func (m *Mux) Handle(pattern string, fn response.HandlerFunc) { - for _, mw := range m.middleware { + defer m.traced("Handle", "", "pattern", pattern)(nil) + m.Mux.Handle(pattern, response.Handler(m.applyMiddleware(fn))) +} + +func (m *Mux) applyMiddleware(fn response.HandlerFunc) response.HandlerFunc { + return applyMiddleware(fn, m.middleware...) +} + +func (m *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) { + m.Mux.ServeHTTP(w, r) +} + +// TODO: support for methods? +// TODO: strip the prefix from the call to the subrouter handler call +// Route does not accept methods or ... wildcards +// func (m *Mux) Route(basePathPattern string, sr Subrouter) { +func (m *Mux) Route(pattern string, sr Subrouter) { + defer m.traced("Route", "pattern", pattern)(nil) + + // --- + method, segments, _ := getHTTPMethodAndPathSegments(m.log, pattern) + + if pattern == "" || (len(segments) == 0 && pattern[len(pattern)-1] != '/') { + panic("invalid subpath: " + pattern) + } + + numPatternSegments := len(segments) + + handler := response.Handler(m.applyMiddleware(buildSubrouterHandlerFunc(m.log, numPatternSegments, sr))) + + cleanPattern := "/" + if joinedSegments := strings.Join(segments, "/"); joinedSegments != "" { + cleanPattern = "/" + joinedSegments + "/" + } + if method != "" { + cleanPattern = method + " " + cleanPattern + } + logTrace(m.log, "mux", "Route", "cleanPattern: "+cleanPattern) + // --- + + /* + trimmedSubpathPattern := strings.Trim(path.Clean(basePathPattern), "/") + if trimmedSubpathPattern == "" && basePathPattern != "/" { + panic("invalid subpath: " + basePathPattern) + } + + var trimmedPatternSegments []string + if trimmedSubpathPattern != "" { + trimmedPatternSegments = strings.Split(trimmedSubpathPattern, "/") + } + numPatternSegments := len(trimmedPatternSegments) + + handler := response.Handler(m.applyMiddleware(buildSubrouterHandlerFunc(numPatternSegments, sr))) + + cleanPattern := "/" + if joinedSegments := strings.Join(trimmedPatternSegments, "/"); joinedSegments != "" { + cleanPattern = "/" + joinedSegments + "/" + } + fmt.Println("Mux.Route: cleanPattern:", cleanPattern) + */ + + if method != "" { + m.Mux.Handle(method+" "+cleanPattern, handler) + } else { + m.Mux.Handle("GET "+cleanPattern, handler) + m.Mux.Handle("POST "+cleanPattern, handler) + m.Mux.Handle("PUT "+cleanPattern, handler) + m.Mux.Handle("PATCH "+cleanPattern, handler) + m.Mux.Handle("DELETE "+cleanPattern, handler) + } + +} + +type ( + Subrouter interface { + Handler(r *http.Request) (fn response.HandlerFunc, pathParams map[string]string, found bool) + } + + SubMux struct { + tree *muxTree + middleware []response.Middleware + log *slog.Logger + } +) + +func NewSubMux(log *slog.Logger, ms ...response.Middleware) *SubMux { + return &SubMux{ + tree: newMuxTree(log.WithGroup("muxTree")), + middleware: ms, + log: log, + } +} + +func (m *SubMux) traced(method, msg string, args ...any) func(func() (finalArgs []any)) { + return logTraceAndDefer(m.log, "SubMux", method, msg, args...) +} + +// TODO: this is capturing all subroutes! +func (m *SubMux) Handle(pattern string, fn response.HandlerFunc) { + defer m.traced("Handle", "", "pattern", pattern)(nil) + m.tree.set(pattern, m.applyMiddleware(fn)) +} + +// TODO: need http method support +// Route does not accept methods or ... wildcards +// func (m *SubMux) Route(basePathPattern string, sr Subrouter) { +func (m *SubMux) Route(pattern string, sr Subrouter) { + //defer m.traced("Route", "", "basePathPattern", basePathPattern)(nil) + defer m.traced("Route", "", "pattern", pattern)(nil) + method, segments, _ := getHTTPMethodAndPathSegments(m.log, pattern) + + if pattern == "" || (len(segments) == 0 && pattern[len(pattern)-1] != '/') { + panic("invalid subpath: " + pattern) + } + + numPatternSegments := len(segments) + + handler := m.applyMiddleware(buildSubrouterHandlerFunc(m.log, numPatternSegments, sr)) + + cleanPattern := "/" + if joinedSegments := strings.Join(segments, "/"); joinedSegments != "" { + cleanPattern = "/" + joinedSegments + "/" + } + cleanPattern = path.Clean(cleanPattern) + if method != "" { + cleanPattern = method + " " + cleanPattern + } + logTrace(m.log, "SubMux", "Route", "Handle about to be called", "cleanPattern", cleanPattern) + // --- + + /* + // --- + trimmedSubpathPattern := strings.Trim(path.Clean(basePathPattern), "/") + if trimmedSubpathPattern == "" && basePathPattern != "/" { + panic("invalid subpath: " + basePathPattern) + } + + var trimmedPatternSegments []string + if trimmedSubpathPattern != "" { + trimmedPatternSegments = strings.Split(trimmedSubpathPattern, "/") + } + numPatternSegments := len(trimmedPatternSegments) + + handler := m.applyMiddleware(buildSubrouterHandlerFunc(m.log, numPatternSegments, sr)) + + // TODO: we're wrapping the pattern method! + cleanPattern := "/" + if joinedSegments := strings.Join(trimmedPatternSegments, "/"); joinedSegments != "" { + cleanPattern = "/" + joinedSegments + "/" + } + // --- + */ + + m.Handle(cleanPattern, handler) + + /* + m.Mux.Handle("GET "+cleanPattern, handler) + m.Mux.Handle("POST "+cleanPattern, handler) + m.Mux.Handle("PUT "+cleanPattern, handler) + m.Mux.Handle("PATCH "+cleanPattern, handler) + m.Mux.Handle("DELETE "+cleanPattern, handler) + */ +} + +// // Route does not accept methods or ... wildcards +// func (m *SubMux) Route(basePathPattern string, sr Subrouter) { +// trimmedSubpathPattern := strings.Trim(path.Clean(basePathPattern), "/") +// if trimmedSubpathPattern == "" && basePathPattern != "/" { +// panic("invalid subpath: " + basePathPattern) +// } +// +// var trimmedPatternSegments []string +// if trimmedSubpathPattern != "" { +// trimmedPatternSegments = strings.Split(trimmedSubpathPattern, "/") +// } +// numPatternSegments := len(trimmedPatternSegments) +// +// handler := m.applyMiddleware(buildSubrouterHandlerFunc(numPatternSegments, sr)) +// +// // TODO: we're wrapping the pattern method! +// cleanPattern := "/" +// if joinedSegments := strings.Join(trimmedPatternSegments, "/"); joinedSegments != "" { +// cleanPattern = "/" + joinedSegments + "/" +// } +// +// m.Handle(cleanPattern, handler) +// +// /* +// m.Mux.Handle("GET "+cleanPattern, handler) +// m.Mux.Handle("POST "+cleanPattern, handler) +// m.Mux.Handle("PUT "+cleanPattern, handler) +// m.Mux.Handle("PATCH "+cleanPattern, handler) +// m.Mux.Handle("DELETE "+cleanPattern, handler) +// */ +// } + +func buildSubrouterHandlerFunc(logger *slog.Logger, numPatternSegments int, sr Subrouter) response.HandlerFunc { + defer logTraceAndDefer(logger, "", "buildSubrouterHandlerFunc", "", "numPatternSegments", numPatternSegments)(nil) + return func(r *http.Request) (res response.Response, err error) { + defer logTraceAndDefer(logger, "", "buildSubrouterHandlerFunc", "", "numPatternSegments", numPatternSegments, "r.URL", r.URL)(func() []any { + return []any{ + "res", res, + "err", err, + } + }) + + fullPath := r.URL.Path + + // set trailing path on request + + segments, trailingSlash := getPathSegments(logger, r.URL.Path) + + /* + endsInSlash := strings.HasSuffix(r.URL.Path, "/") + segments := strings.Split(strings.Trim(r.URL.Path, "/"), "/") + */ + tail := segments[numPatternSegments:] + tailStr := "/" + if len(tail) > 0 { + tailStr += strings.Join(tail, "/") + if trailingSlash { + tailStr += "/" + } + } + + r.URL.Path = tailStr + defer func() { + // reset the request path + r.URL.Path = fullPath + }() + + // do the request + + logTrace(logger, "", "buildSubrouterHandlerFunc", "handler called", "subpath", r.URL.Path) + fn, params, ok := sr.Handler(r) + logTrace(logger, "", "buildSubrouterHandlerFunc", "handler returned", "subpath", r.URL.Path, "fn", fn, "params", params, "ok", ok) + if !ok { + return nil, response.NotFound().Wrap(ErrHandlerNotFound) + } + + for k, v := range params { + r.SetPathValue(k, v) + } + + res, err = fn(r) + + return res, err + } +} + +func (m *SubMux) Handler(r *http.Request) (fn response.HandlerFunc, params map[string]string, found bool) { + defer m.traced("Handler", "", "r.URL", r.URL)(func() []any { + return []any{ + "params", params, + "found", found, + } + }) + return m.tree.get(r.Method, r.URL.Path) +} + +func (m *SubMux) applyMiddleware(fn response.HandlerFunc) response.HandlerFunc { + return applyMiddleware(fn, m.middleware...) +} + +// path pattern matching tree implementation + +type ( + muxTree struct { + log *slog.Logger + + branches map[string]*muxTree + wildcardKey string + wildcardBranch *muxTree + + method string + handler response.HandlerFunc + subrouteHandler response.HandlerFunc + } +) + +func newMuxTree(log *slog.Logger) *muxTree { + return &muxTree{ + log: log, + branches: make(map[string]*muxTree), + } +} + +func (m *muxTree) traced(method, msg string, args ...any) func(func() (finalArgs []any)) { + return logTraceAndDefer(m.log, "muxTree", method, msg, args...) +} + +func (m *muxTree) String() string { + if m == nil { + return "" + } + + kvs := make([]string, 0, 6) + + if m.subrouteHandler != nil { + kvs = append(kvs, fmt.Sprintf("subrouteHandler: %v", m.subrouteHandler)) + } + if m.method != "" { + kvs = append(kvs, fmt.Sprintf("method: %v", m.method)) + } + if m.handler != nil { + kvs = append(kvs, fmt.Sprintf("handler: %v", m.handler)) + } + if m.wildcardKey != "" { + kvs = append(kvs, fmt.Sprintf("wildcardKey: %v", m.wildcardKey)) + } + if m.wildcardBranch != nil { + kvs = append(kvs, fmt.Sprintf("wildcardBranch: %v", m.wildcardBranch)) + } + if len(m.branches) > 0 { + branchKvs := make([]string, 0, len(m.branches)) + for k, v := range m.branches { + branchKvs = append(branchKvs, fmt.Sprintf("%q: %s", k, v)) + } + + slices.Sort(branchKvs) + + kvs = append(kvs, fmt.Sprintf("branches: {%s}", strings.Join(branchKvs, ", "))) + } + + return fmt.Sprintf("{%s}", strings.Join(kvs, ", ")) +} + +func (m *muxTree) set(pattern string, fn response.HandlerFunc) { + defer m.traced("set", "", "pattern", pattern)(func() []any { + return []any{ + "muxTree", m, + } + }) + method, segments, trailingSlash, endOfURLWildcard := getHTTPMethodAndPathSegmentsDroppingEndOfURLWildcard(m.log, pattern) + m.setBySegments( + method, + segments, + trailingSlash, + endOfURLWildcard, + fn, + ) +} + +// TODO: handle /{$} properly +func (m *muxTree) setBySegments(method string, segments []string, trailingSlash, endOfURLWildcard bool, fn response.HandlerFunc) { + defer m.traced( + "setBySegments", + "", + "method", method, + "segments", segments, + "trailingSlash", trailingSlash, + "endOfURLWildcard", endOfURLWildcard, + )(func() []any { + return []any{ + "muxTree", m, + } + }) + if len(segments) == 0 { + m.method = method + if !trailingSlash || endOfURLWildcard { + m.handler = fn + } else if trailingSlash { + m.subrouteHandler = fn + } + return + } + + head := segments[0] + tail := segments[1:] + + var sub *muxTree + if key, ok := getWildcardPathSegmentKey(head); ok { + m.wildcardKey = key + if sub = m.wildcardBranch; sub == nil { + sub = newMuxTree(m.log) + m.wildcardBranch = sub + } + } else { + if sub = m.branches[head]; sub == nil { + sub = newMuxTree(m.log) + m.branches[head] = sub + } + } + + /* + if isWildcard, wildcardKey := isWildcardPathSegment(head); wildcardKey == "$" { + // TODO: test + // TODO: handle trailing end path matching + if len(tail) > 0 || trailingSlash { + panic("{$} wildcard applied outside of end of path") + } + m.method = method + m.handler = fn + return + } else if isWildcard { + m.wildcardKey = wildcardKey + if sub = m.wildcardBranch; sub == nil { + sub = newMuxTree(m.log) + m.wildcardBranch = sub + } + } else { + if sub = m.branches[head]; sub == nil { + sub = newMuxTree(m.log) + m.branches[head] = sub + } + } + */ + + sub.setBySegments(method, tail, trailingSlash, endOfURLWildcard, fn) +} + +func (m *muxTree) get(method, pattern string) (fn response.HandlerFunc, params map[string]string, found bool) { + defer m.traced("get", "", "method", method, "pattern", pattern)(func() []any { + return []any{ + "fn", fn, + "params", params, + "found", found, + } + }) + segments, trailingSlash := getPathSegments(m.log, pattern) + return m.getByHTTPMethodAndSegments(method, segments, trailingSlash) +} + +func (m *muxTree) getByHTTPMethodAndSegments(method string, segments []string, trailingSlash bool) (fn response.HandlerFunc, params map[string]string, found bool) { + defer m.traced("getByHTTPMethodAndSegments", "", "method", method, "segments", segments, "trailingSlash", trailingSlash)(func() []any { + return []any{ + "fn", fn, + "params", params, + "found", found, + } + }) + if len(segments) == 0 { + logTrace(m.log, "muxTree", "getByHTTPMethodAndSegments", "no segments", "tree", m) + if methodMatches := m.method == "" || m.method == method; methodMatches { + if fn = m.handler; fn == nil { + fn = m.subrouteHandler + } + return fn, map[string]string{}, fn != nil + } + return nil, map[string]string{}, false + } + + head := segments[0] + tail := segments[1:] + + if m.subrouteHandler != nil { + defer func() { + if !found && (m.method == "" || m.method == method) { + fn = m.subrouteHandler + found = true + } + }() + } + + if sm, ok := m.branches[head]; ok { + logTrace(m.log, "muxTree", "getByHTTPMethodAndSegments", "matching branch found", "head", head, "sm", sm) + //return sm.getByHTTPMethodAndSegments(method, tail, trailingSlash) + if fn, params, found = sm.getByHTTPMethodAndSegments(method, tail, trailingSlash); found { + return fn, params, found + } + logTrace(m.log, "muxTree", "getByHTTPMethodAndSegments", "matching branch mismatched at subpath") + } else { + logTrace(m.log, "muxTree", "getByHTTPMethodAndSegments", "no matching branch found") + } + if sm := m.wildcardBranch; sm != nil { + logTrace(m.log, "muxTree", "getByHTTPMethodAndSegments", "wildcard branch found", "m.wildcardKey", m.wildcardKey, "sm", sm) + //fn, params, found = sm.getByHTTPMethodAndSegments(method, tail, trailingSlash) + if fn, params, found = sm.getByHTTPMethodAndSegments(method, tail, trailingSlash); found { + params[m.wildcardKey] = head + return fn, params, found + } + logTrace(m.log, "muxTree", "getByHTTPMethodAndSegments", "wildcard branch mismatched at subpath") + } else { + logTrace(m.log, "muxTree", "getByHTTPMethodAndSegments", "no wildcard branch found") + } + + // --- TODO: test --- + if m.subrouteHandler != nil && (m.method == method || m.method == "") { + logTrace(m.log, "muxTree", "getByHTTPMethodAndSegments", "subroutes captured", "m", m) + return m.subrouteHandler, nil, true + } + + logTrace(m.log, "muxTree", "getByHTTPMethodAndSegments", "subroutes not captured", "m", m) + // --- + + return nil, map[string]string{}, false +} + +func getHTTPMethodAndPathSegmentsDroppingEndOfURLWildcard(logger *slog.Logger, pattern string) (method string, segments []string, trailingSlash, endOfURLWildcard bool) { + defer logTraceAndDefer(logger, "", "getHTTPMethodAndPathSegmentsDroppingEndOfURLWildcard", "", "pattern", pattern)(func() []any { + return []any{ + "method", method, + "segments", segments, + "trailingSlash", trailingSlash, + "endOfURLWildcard", endOfURLWildcard, + } + }) + if vs := strings.SplitN(pattern, " ", 2); len(vs) == 2 { + method = vs[0] + pattern = vs[1] + } + segments, trailingSlash, endOfURLWildcard = getPathSegmentsWithoutEndOfURLWildcard(logger, pattern) + + return method, segments, trailingSlash, endOfURLWildcard +} + +func getHTTPMethodAndPathSegments(logger *slog.Logger, pattern string) (method string, segments []string, trailingSlash bool) { + defer logTraceAndDefer(logger, "", "getHTTPMethodAndPathSegments", "", "pattern", pattern)(func() []any { + return []any{ + "method", method, + "segments", segments, + "trailingSlash", trailingSlash, + } + }) + if vs := strings.SplitN(pattern, " ", 2); len(vs) == 2 { + method = vs[0] + pattern = vs[1] + } + segments, trailingSlash = getPathSegments(logger, pattern) + + return method, segments, trailingSlash +} + +func getPathSegments(logger *slog.Logger, pathPattern string) (segments []string, trailingSlash bool) { + defer logTraceAndDefer(logger, "", "getPathSegments", "", "pathPattern", pathPattern)(func() []any { + return []any{ + "segments", segments, + "trailingSlash", trailingSlash, + } + }) + trailingSlash = strings.HasSuffix(pathPattern, "/") + + p := strings.Trim(path.Clean(pathPattern), "/") + if p == "" { + return nil, trailingSlash + } + + return strings.Split(p, "/"), trailingSlash +} + +// NOTE: if endOfURLWildcard, then trailingSlash +func getPathSegmentsWithoutEndOfURLWildcard(logger *slog.Logger, pathPattern string) (segments []string, trailingSlash, endOfURLWildcard bool) { + defer logTraceAndDefer(logger, "", "getPathSegmentsWithoutEndOfURLWildcard", "", "pathPattern", pathPattern)(func() []any { + return []any{ + "segments", segments, + "trailingSlash", trailingSlash, + "endOfURLWildcard", endOfURLWildcard, + } + }) + + endOfURLWildcard = strings.HasSuffix(pathPattern, "/{$}") + if endOfURLWildcard { + pathPattern = pathPattern[:len(pathPattern)-3] + } + + trailingSlash = strings.HasSuffix(pathPattern, "/") + + p := strings.Trim(path.Clean(pathPattern), "/") + if p == "" { + return nil, trailingSlash, endOfURLWildcard + } + + return strings.Split(p, "/"), trailingSlash, endOfURLWildcard +} + +func getWildcardPathSegmentKey(s string) (string, bool) { + if isWildcard := len(s) > 2 && s[0] == '{' && s[len(s)-1] == '}'; isWildcard { + return s[1 : len(s)-1], true + } + return "", false +} + +func applyMiddleware(fn response.HandlerFunc, ms ...response.Middleware) response.HandlerFunc { + for _, mw := range slices.Backward(ms) { prev := fn fn = mw(func(r *http.Request) (response.Response, error) { return prev(r) }) } - m.Mux.Handle(pattern, response.Handler(fn)) + return fn } -func (m *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) { - m.Mux.ServeHTTP(w, r) +// logging utilities + +func logTraceAndDefer(logger *slog.Logger, typeName, method, msg string, args ...any) func(func() (finalArgs []any)) { + fmsg := fmt.Sprintf("%s.%s called", typeName, method) + if msg != "" { + fmsg = fmt.Sprintf("%s: %s", fmsg, msg) + } + + logger.Log(nil, consts.LevelTrace, fmsg, args...) + return func(cb func() (finalArgs []any)) { + if cb != nil { + args = append(args, cb()...) + } + fmsg := fmt.Sprintf("%s.%s returned", typeName, method) + if msg != "" { + fmsg = fmt.Sprintf("%s: %s", fmsg, msg) + } + + logger.Log(nil, consts.LevelTrace, fmsg, args...) + } +} + +func logTrace(logger *slog.Logger, typeName, method, msg string, args ...any) { + fmsg := fmt.Sprintf("%s.%s", typeName, method) + if msg != "" { + fmsg = fmt.Sprintf("%s: %s", fmsg, msg) + } + + logger.Log(nil, consts.LevelTrace, fmsg, args...) } diff --git a/internal/server/server.go b/internal/server/server.go index 7e3811e..6f1ab88 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,6 +3,7 @@ package server import ( "context" "encoding/json" + "errors" "fmt" "html/template" "log/slog" @@ -12,11 +13,13 @@ import ( "github.com/angelbeltran/templater" - "ruben/inventory2/internal/consts" "ruben/inventory2/internal/domains/accounts" "ruben/inventory2/internal/domains/authentication" etsy_platform "ruben/inventory2/internal/domains/platforms/etsy" "ruben/inventory2/internal/domains/raw_events" + accounts_api "ruben/inventory2/internal/server/api/accounts" + auth_api "ruben/inventory2/internal/server/api/auth" + templates_api "ruben/inventory2/internal/server/api/templates" "ruben/inventory2/internal/server/middleware" "ruben/inventory2/internal/server/response" "ruben/inventory2/internal/server/router" @@ -26,7 +29,7 @@ import ( type Server struct { log *slog.Logger - http.Handler + *router.Mux contentDir string templater *templater.Templater rawEvents *raw_events.Store @@ -45,97 +48,124 @@ func NewServer( etsy *etsy_platform.Platform, auth *authentication.Authenticator, ) *Server { - mux := router.NewMux(middleware.LogRequests(ctx, logger.WithGroup("request"))) + mux := router.NewMux( + logger, + middleware.LogRequests(ctx, logger.WithGroup("request")), + ) + + tmpl := templater.NewTemplater( + contentDir+"/templates", + func() template.FuncMap { + return template.FuncMap{ + // params + "addPathParam": func(k string, v any, args map[string]any) (map[string]any, error) { + pathParams, ok := args["PathParams"].(map[string]string) + if !ok { + return nil, fmt.Errorf("PathParams no set are args: %v", args) + } + + pathParams[k] = fmt.Sprint(v) + + return args, nil + }, + + // parsing + "parseInt": func(s string) (int, error) { + return strconv.Atoi(s) + }, + "parseInt64": func(s string) (int64, error) { + return strconv.ParseInt(s, 10, 64) + }, + "parsePlatform": func(s string) (accounts.Platform, error) { + return accounts.NewPlatform(s) + }, + + // arithmetic + "addInt": func(a, b int) int { + return a + b + }, + "subInt": func(a, b int) int { + return a - b + }, + "multInt": func(a, b int) int { + return a * b + }, + + // json + "prettyPrintJSON": func(j json.RawMessage) string { + b, err := json.MarshalIndent(j, " ", "") + if err != nil { + return string(j) + } + return string(b) + }, + } + }, + ) s := &Server{ + // TODO: eliminate fields that aren't needed anymore log: logger, - Handler: mux, + Mux: mux, contentDir: contentDir, - templater: templater.NewTemplater( - contentDir+"/templates", - func() template.FuncMap { - return template.FuncMap{ - // params - "addPathParam": func(k string, v any, args map[string]any) (map[string]any, error) { - pathParams, ok := args["PathParams"].(map[string]string) - if !ok { - return nil, fmt.Errorf("PathParams no set are args: %v", args) - } - - pathParams[k] = fmt.Sprint(v) - - return args, nil - }, - - // parsing - "parseInt": func(s string) (int, error) { - return strconv.Atoi(s) - }, - "parseInt64": func(s string) (int64, error) { - return strconv.ParseInt(s, 10, 64) - }, - "parsePlatform": func(s string) (accounts.Platform, error) { - return accounts.NewPlatform(s) - }, - - // arithmetic - "addInt": func(a, b int) int { - return a + b - }, - "subInt": func(a, b int) int { - return a - b - }, - "multInt": func(a, b int) int { - return a * b - }, - - // json - "prettyPrintJSON": func(j json.RawMessage) string { - b, err := json.MarshalIndent(j, " ", "") - if err != nil { - return string(j) - } - return string(b) - }, - } - }, - ), - rawEvents: rawEvents, - accts: accts, - etsy: etsy, - auth: auth, + templater: tmpl, + rawEvents: rawEvents, + accts: accts, + etsy: etsy, + auth: auth, // TODO: shouldn't this ACTUALLY be a middleware? + // - only try to make this an actual middleware AFTER all the routers are broken out, so that way how the middleware is supposed to work can be known authMiddleware: middleware.NewAuth( logger.WithGroup("auth-middleware"), auth, - newLoginURL, + auth_api.NewLoginURL, accts, ), } - withAuth := func(fn response.HandlerFunc) response.HandlerFunc { - return s.authMiddleware.AuthenticateAndAddIdentity(fn) - } + // kind of a dumb way to capture routes for webpages + wpr := templates_api.NewWebpageRouter( + s.log.WithGroup("templates"), + contentDir, + tmpl, + rawEvents, + accts, + etsy, + s.authMiddleware, + ) + mux.AddMiddleware( + func(fn response.HandlerFunc) response.HandlerFunc { + return func(r *http.Request) (response.Response, error) { + res, err := fn(r) + if err == nil || !errors.Is(err, router.ErrHandlerNotFound) { + return res, err + } - // login + renderWebPage, pathParams, found := wpr.Handler(r) + if !found { + return nil, response.NotFound() + } - // TODO: consider a separate '/login or /auth' router - mux.Handle("GET /login", s.loginPage) - mux.Handle("GET /login/callback", s.loginCallback) - mux.Handle("GET /logout", s.logoutPage) + for k, v := range pathParams { + r.SetPathValue(k, v) + } - // TODO: consider a separate '/accounts' router - // /accounts + return renderWebPage(r) + } + }, + ) - mux.Handle("POST /accounts", withAuth(s.createAccount)) - mux.Handle("POST /accounts/{acctID}/inventory/sync-groups/draft/listings", withAuth(s.createSyncGroupListingDraft)) - mux.Handle("PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/shop", withAuth(s.setShopInSyncGroupListingDraft)) - mux.Handle("PUT /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}/listing", withAuth(s.setListingInSyncGroupListingDraft)) - mux.Handle("DELETE /accounts/{acctID}/inventory/sync-groups/draft/listings/{orderIndex}", withAuth(s.deleteSyncGroupListingDraft)) - mux.Handle("POST /accounts/{acctID}/inventory/sync-groups", withAuth(s.saveNewSyncGroup)) + // api endpoints + + mux.Route("/auth", auth_api.NewLoginSubrouter(s.log.WithGroup("/auth"), auth)) + mux.Route("/accounts", accounts_api.NewAccountSubrouter( + s.log.WithGroup("/accounts"), + s.accts, + s.authMiddleware, + )) + + // api webhooks (TODO: make a router for these) - // TODO: consider a webhook separate router - // webhooks webhookHandler := http.StripPrefix("/webhooks", webhooks.New( logger.WithGroup("webhooks"), rawEvents, @@ -150,8 +180,6 @@ func NewServer( mux.Mux.Handle("POST /webhooks/", webhookHandler) mux.Mux.Handle("PUT /webhooks/", webhookHandler) - // TODO: consider a separate webpage router - // webpage content // non-html content: scripts, styles, images, etc @@ -165,56 +193,11 @@ func NewServer( scfs.ServeHTTP(w, r) }))) mux.Mux.Handle("GET /styles/", http.StripPrefix("/styles", http.FileServer(http.Dir(contentDir+"/styles")))) + mux.Mux.Handle("GET /favicon/", http.StripPrefix("/favicon", http.FileServer(http.Dir(contentDir+"/favicon")))) // html - // non-authenticated - mux.Handle("GET /{$}", s.authMiddleware.AddIdentity(s.serveTemplates)) - // authenticated - mux.Handle("GET /", withAuth(s.serveTemplates)) + mux.Route("/", wpr) return s } - -func mapConstantErrorsToHTTPErrors(err error) error { - cerr := err - for cerr != nil { - switch cerr { - case consts.ErrNotFound: - return response.NotFound() - case consts.ErrConflict: - return response.Conflict() - } - - uerr, ok := cerr.(interface { - Unwrap() error - }) - if !ok { - return err - } - - cerr = uerr.Unwrap() - } - return err -} - -func newRequestIDProvider(ctx context.Context) <-chan int { - reqIDCh := make(chan int) - - go func() { - defer close(reqIDCh) - - nextReqID := 1 - - for { - select { - case reqIDCh <- nextReqID: - nextReqID += 1 - case <-ctx.Done(): - return - } - } - }() - - return reqIDCh -} diff --git a/main.go b/main.go index 45a2621..ff8ff10 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,7 @@ func main() { logger := slog.New(tint.NewHandler(os.Stderr, &tint.Options{ AddSource: true, Level: slog.LevelDebug, + //Level: consts.LevelTrace, ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { // this can perform general key=value log cleanup diff --git a/templates/layout.html.tmpl b/templates/layout.html.tmpl index 2a9fc3d..50115f1 100644 --- a/templates/layout.html.tmpl +++ b/templates/layout.html.tmpl @@ -13,6 +13,11 @@ + + + + + {{/* This is where the page_head template will be inserted into the page */}} {{- block "head" . }}{{ end }} @@ -40,7 +45,7 @@ {{- if not $userID }}
  • - + Log In / Sign Up
  • @@ -66,7 +71,7 @@
  • - + Log Out