From 0d6af06d65008164b49e09cb1caa5b841b901ffc Mon Sep 17 00:00:00 2001 From: Angel Beltran Date: Mon, 12 Jan 2026 04:54:53 -0700 Subject: [PATCH] subrouters --- favicon/about.txt | 6 + favicon/android-chrome-192x192.png | Bin 0 -> 10514 bytes favicon/android-chrome-512x512.png | Bin 0 -> 36568 bytes favicon/apple-touch-icon.png | Bin 0 -> 9537 bytes favicon/favicon-16x16.png | Bin 0 -> 507 bytes favicon/favicon-32x32.png | Bin 0 -> 1115 bytes favicon/favicon.ico | Bin 0 -> 15406 bytes favicon/site.webmanifest | 1 + internal/consts/logging.go | 7 + internal/domains/authentication/auth.go | 2 +- .../{accounts.go => api/accounts/router.go} | 66 +- .../server/{login.go => api/auth/router.go} | 55 +- .../{templates.go => api/templates/router.go} | 61 +- internal/server/middleware/log.go | 1 + internal/server/response/body.go | 4 +- internal/server/response/cookie.go | 4 +- internal/server/response/error.go | 25 + internal/server/response/html.go | 4 +- internal/server/response/json.go | 4 +- internal/server/response/redirect.go | 2 +- internal/server/response/response.go | 2 +- internal/server/response/status.go | 4 +- internal/server/response/write.go | 2 +- internal/server/router/mux.go | 631 +++++++++++++++++- internal/server/server.go | 233 +++---- main.go | 1 + templates/layout.html.tmpl | 9 +- 27 files changed, 950 insertions(+), 174 deletions(-) create mode 100644 favicon/about.txt create mode 100644 favicon/android-chrome-192x192.png create mode 100644 favicon/android-chrome-512x512.png create mode 100644 favicon/apple-touch-icon.png create mode 100644 favicon/favicon-16x16.png create mode 100644 favicon/favicon-32x32.png create mode 100644 favicon/favicon.ico create mode 100644 favicon/site.webmanifest create mode 100644 internal/consts/logging.go rename internal/server/{accounts.go => api/accounts/router.go} (64%) rename internal/server/{login.go => api/auth/router.go} (54%) rename internal/server/{templates.go => api/templates/router.go} (75%) 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 0000000000000000000000000000000000000000..70dd800788e37b77be4fd35636cebea0dafa1fe1 GIT binary patch literal 10514 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 literal 0 HcmV?d00001 diff --git a/favicon/favicon-16x16.png b/favicon/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..a27391f8bc8a584ccfe76386926fbed0297453e3 GIT binary patch literal 507 zcmVDQP4^x z3L@6d^WNAyOI9LGGRe%mng9Fx4Tv8`BsSY1l{^HpyRL4_rc>uZSJ`fcW!a_S3Q@J5 zwy${~9k+X>#N7`o_GOlmW<3#w=I#|RA435s!g-X-~EhSCu1;uOs@Sp3qSGRt1ZT_m5LUbY?VRcVL zpb#fQ>wTjjWhY&1_SCei^+wkkeZ1Ai6g|hfj1^R(yaMC-12sf#HextN=** zY|sA}7#N#s42MHCly|=X+5MXosEs)+q)-UQUqoUdvGLm?76gLYh=_ZpRRCT7HZpAF zAX{uB;|U5c{|u64S%h+xL75>Tiq51`(;(1883<+9hcXh%@)Knah5v&rf2(FZdH>UA xV(j%}==RHJ3hKF6)I+v8j?=!ix|k2T_YOlK@3l)$)fxZ*002ovPDHLkV1n?j=5qi5 literal 0 HcmV?d00001 diff --git a/favicon/favicon-32x32.png b/favicon/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..a6368fe94604a3b07df2cd57dac6d15a6e807d06 GIT binary patch literal 1115 zcmV-h1f=_kP)~ZZut?sZ^17m+nd-oX>&}_FQfT4kG&)2p3s|!FpnYzev zH#iTYLOz?`_g(-rwrZcMiu{65{4$u+?zx?|B)Tskg6lAU0I`I&7D;X`sp8u3G3~M2 zmnQ@9)}tFhjf`-F3xMbqNx65RKl77i*r;hUqc7bR2Y`5@>kyzg>2=N^2n0J*L)Dn5#G@fOkhh1w3kbi> z=d@G40E~JGkiXechdMCqCeCJA(w3qA-n+g4%n@tr{2Yb26as`KAfv7Z4;UpA)p2k? z>CS$t*!rwW^KkuqQb+`MIJ?>6%hHW=r;kk-<_8WQng7u&b-MzXVBqWBE}0`HLD%=# zn>jE8#*5~T&Xo}<{;=1acyVSj6OZoq6bc314|qdEgEDvVs&0;|6M5Mhxx`JpW~eVS z>bCREKueODj&ayGOQPPvTsjq|+_6L|EfBlxdxMzL1Hhc~qI~WcHKN-SPabXxirfv# z$i)1`tA2NsgP=G$8yDGm!t8;{#Uk7Ian2h@^Ei19lF>(%qpXb(T zLomXb_`9-){wk&F9h%ax)X|2Ox-wd4@CpJb73b6;D!W z?eOs*{tQLM)h;8Bbo9#l^(E}~X)HfW>Te)OGFz$0sA-@VQ$ygh* z0Yp7vP!&nm*m{ITY1AW13FTfLVT?7xVe@+cAwZNuk#k)D5`BPjDIZ>&C^gVFu`E~B zC=&(+%5t?xSKT)zn|frqy`PwbULcmX<+I-h%W^ASu|($*B=MzA^s`++TJLN5Fllb< zS{D(@Rte>6b~$eOs_WCN764PVa$NxUnjK)rF}w5s_2aau{kzqw61_t5&F4A7IF)JF hz>mH6r;6D9{x!XH7fYSfySV@W002ovPDHLkV1nij5s3f* literal 0 HcmV?d00001 diff --git a/favicon/favicon.ico b/favicon/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..c5369b71785eb97e3ef29cd6baa9244c4b9f6d00 GIT binary patch literal 15406 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 QZXFh%t8}hvU>F+sKLo=QZ2$lO literal 0 HcmV?d00001 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 }} @@ -66,7 +71,7 @@
  • - + Log Out
  • - + Log In / Sign Up