removed intermediate /internal directory

This commit is contained in:
2026-02-09 13:40:31 -07:00
parent b155280081
commit 0944703d2a
50 changed files with 117 additions and 101 deletions
+491
View File
@@ -0,0 +1,491 @@
package accounts
import (
"errors"
"fmt"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"ruben/inventory2/consts"
"ruben/inventory2/domains/accounts"
"ruben/inventory2/logging"
"ruben/inventory2/server/auth"
"ruben/inventory2/server/param"
"ruben/inventory2/server/response"
"ruben/inventory2/server/sse"
)
type accountSubrouter struct {
log *logging.Logger
accts *accounts.Store
pub *sse.UpdateNotificationPublisher
}
func Routes(
r *gin.RouterGroup,
logger *logging.Logger,
accts *accounts.Store,
pub *sse.UpdateNotificationPublisher,
) {
as := &accountSubrouter{
log: logger,
accts: accts,
pub: pub,
}
r.POST("", response.Handler(as.createAccount))
platformGroup := r.Group("/:acctID/platforms/:platform", pub.Publish("/:acctID/platforms"))
platformGroup.PUT("/order-index", response.Handler(as.setOrderOfPlatformOnAccountPage))
platformGroup.POST("/shops/mocks", response.Handler(as.createMockShop))
mockShops := platformGroup.Group("/shops/mocks/:shop-id")
mockShops.POST("/listings", response.Handler(as.addMockListing))
mockShops.PUT("/listings/:listing-id", response.Handler(as.updateMockListing))
mockShops.DELETE("/listings/:listing-id", response.Handler(as.deleteMockListing))
syncGroups := r.Group("/:acctID/inventory/sync-groups")
syncGroups.POST("", pub.Publish("/:acctID/inventory/sync-groups"), response.Handler(as.saveNewSyncGroup))
draftListings := syncGroups.Group("/draft/listings", pub.Publish("/:acctID/inventory/sync-groups/draft/listings"))
draftListings.POST("", response.Handler(as.createSyncGroupListingDraft))
draftListings.PUT("/:orderIndex/shop", response.Handler(as.setShopInSyncGroupListingDraft))
draftListings.PUT("/:orderIndex/listing", response.Handler(as.setListingInSyncGroupListingDraft))
draftListings.DELETE("/:orderIndex", response.Handler(as.deleteSyncGroupListingDraft))
mockSyncGroups := syncGroups.Group("/mock")
mockDraftListings := mockSyncGroups.Group("/draft/listings", pub.Publish("/:acctID/inventory/sync-groups/mock/draft/listings"))
mockDraftListings.POST("", response.Handler(as.createMockSyncGroupListingDraft))
mockDraftListings.PUT("/:orderIndex/shop", response.Handler(as.setShopInMockSyncGroupListingDraft))
mockDraftListings.PUT("/:orderIndex/listing", response.Handler(as.setListingInMockSyncGroupListingDraft))
mockDraftListings.DELETE("/:orderIndex", response.Handler(as.deleteMockSyncGroupListingDraft))
}
// POST /
func (s *accountSubrouter) createAccount(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
// TODO: get email from the user's account profile (how?) - will need to flesh out the account creation process later on
email := uuid.NewString() + "@example.com"
userID := auth.GetIdentity(ctx).User.UserID
_, err := s.accts.CreateAccount(ctx, userID, email)
if err != nil {
if errors.Is(err, consts.ErrConflict) {
return nil, response.Conflict().
Msg("user already has an account")
}
return nil, response.Errorf("failed to create account: %w", err)
}
return response.StatusCreated().
// force a reload to ensure an sse connection is made.
// user should automatically be redirected to the appropriate page based on their current location and/or login/account statuses.
HXRefresh("true"), nil
}
// POST /:acctID/inventory/sync-groups/draft/listings
func (s *accountSubrouter) createSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
acctID := auth.GetIdentity(ctx).Account.AccountID
if _, err := s.accts.CreateSyncGroupListingDraft(ctx, acctID); err != nil {
return nil, response.Errorf("failed to create new listing draft: %w", err)
}
return response.StatusCreated(), nil
}
// PUT /:acctID/inventory/sync-groups/draft/listings/:orderIndex/shop
// @platform string
// @shopID string
func (s *accountSubrouter) setShopInSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
acctID := auth.GetIdentity(ctx).Account.AccountID
var (
orderIndex int
platform accounts.Platform
shopID string
)
err := param.Path("orderIndex", param.Int(&orderIndex)).
Form("platform", param.Platform(&platform)).
Form("shop-id", param.Text(&shopID)).
Unmarshal(c)
if err != nil {
return nil, err
}
if err := s.accts.SetShopInSyncGroupListingDraft(ctx, acctID, orderIndex, platform, shopID); err != nil {
return nil, response.Errorf("failed to set shop: %w", response.ErrorFromConstant(err))
}
return response.StatusOK(), nil
}
// PUT /:acctID/inventory/sync-groups/draft/listings/:orderIndex/listing
func (s *accountSubrouter) setListingInSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
acctID := auth.GetIdentity(ctx).Account.AccountID
var (
orderIndex int
listingID string
)
err := param.Path("orderIndex", param.Int(&orderIndex)).
Form("listing-id", param.Text(&listingID)).
Unmarshal(c)
if err != nil {
return nil, err
}
if err := s.accts.SetListingInSyncGroupListingDraft(ctx, acctID, orderIndex, listingID); err != nil {
return nil, response.Errorf("failed to set listing: %w", response.ErrorFromConstant(err))
}
return response.StatusOK(), nil
}
// DELETE /:acctID/inventory/sync-groups/draft/listings/:orderIndex
func (s *accountSubrouter) deleteSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
acctID := auth.GetIdentity(ctx).Account.AccountID
var (
orderIndex int
)
err := param.Path("orderIndex", param.Int(&orderIndex)).
Unmarshal(c)
if err != nil {
return nil, err
}
if _, err := s.accts.DeleteSyncGroupListingDraft(ctx, acctID, orderIndex); err != nil {
return nil, response.Errorf("failed to delete listing: %w", response.ErrorFromConstant(err))
}
return response.StatusOK(), nil
}
// POST /:acctID/inventory/sync-groups
func (s *accountSubrouter) saveNewSyncGroup(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
acctID := auth.GetIdentity(ctx).Account.AccountID
if _, err := s.accts.SaveNewSyncGroup(ctx, acctID); err != nil {
return nil, response.Errorf("failed to save new sync group: %w", response.ErrorFromConstant(err))
}
return response.StatusCreated(), nil
}
// PUT /:acctID/platforms/:platform/order-index"
// this endpoint is called when dragging a platform tab in the accounts page.
func (s *accountSubrouter) setOrderOfPlatformOnAccountPage(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
// validate parameters
var (
platform accounts.Platform
orderIndex int
)
err := param.Path("platform", param.Platform(&platform)).
Form("order-index", param.Int(&orderIndex)).
Unmarshal(c)
if err != nil {
return nil, err
}
// update the order
platforms, prevIndex, err := s.accts.SetOrderOfPlatformOnAccountPage(c, acctID, platform, orderIndex)
if err != nil {
return nil, fmt.Errorf("failed to save record: %w", err)
}
// emit events on all platforms updated, so the tabs can all refresh (including their logic)
platformEvents := make([]string, max(orderIndex, prevIndex)-min(orderIndex, prevIndex))
if orderIndex > prevIndex {
for i := range orderIndex - prevIndex {
p := platforms[prevIndex+i]
platformEvents[i] = fmt.Sprintf("accounts_%d_platforms_%s_order-index", acctID, lowerSnakeCase(p))
}
} else if orderIndex < prevIndex {
for i := range prevIndex - orderIndex {
p := platforms[orderIndex+1+i]
platformEvents[i] = fmt.Sprintf("accounts_%d_platforms_%s_order-index", acctID, lowerSnakeCase(p))
}
}
if err := s.pub.Push(c, acctID, platformEvents...); err != nil {
s.log.Errorf("failed to publish platform order-index events: %v", err)
}
return response.StatusNoContent(), nil
}
// POST /:acctID/platforms/:platform/shops/mocks
func (s *accountSubrouter) createMockShop(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
// validate parameters
var (
platform accounts.Platform
name string
)
err := param.Path("platform", param.Platform(&platform)).
Form("name", param.Text(&name)).
Unmarshal(c)
if err != nil {
return nil, err
}
s.log.Warnf("not implemented: mock store created: account_id = %d; platform = %s; name = %s", acctID, platform, name)
// create the mock account
id, err := s.accts.CreateMockShop(c, acctID, platform, name)
if err != nil {
return nil, fmt.Errorf("failed to save record: %w", err)
}
location := fmt.Sprintf("/ui/accounts/%d/platforms/%s/mock-shops/%s", acctID, lowerSnakeCase(platform), id)
var res response.Response
if c.GetHeader("HX-Request") == "true" {
res = response.StatusCreated()
} else {
res = response.SeeOther(location)
}
return res.HXLocation(location), nil
}
// POST /:acctID/platforms/:platform/shops/mocks/:shop-id/listings
func (s *accountSubrouter) addMockListing(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
name string
sku string
description string
count int
)
err := param.Path("platform", param.Platform(&platform)).
Path("shop-id", param.Text(&shopID)).
Form("name", param.Text(&name)).
Form("sku", param.Text(&sku)).
Form("description", param.Text(&description)).
Form("count", param.Int(&count)).
Unmarshal(c)
if err != nil {
return nil, err
}
_, err = s.accts.CreateMockListing(
c,
accounts.MockListing{
AccountShopListingIDs: accounts.NewAccountIDs(acctID).
ShopID(platform, shopID).
ListingID(""),
Name: name,
SKU: sku,
Description: description,
Count: int64(count),
},
)
if err != nil {
return nil, fmt.Errorf("failed to create new listing: %w", err)
}
return response.StatusCreated(), nil
}
// PUT /:acctID/platforms/:platform/shops/mocks/:shop-id/listings/:listing-id
func (s *accountSubrouter) updateMockListing(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
listingID string
name string
sku string
description string
count int
)
err := param.Path("platform", param.Platform(&platform)).
Path("shop-id", param.Text(&shopID)).
Path("listing-id", param.Text(&listingID)).
Form("name", param.Text(&name)).
Form("sku", param.Text(&sku)).
Form("description", param.Text(&description)).
Form("count", param.Int(&count)).
Unmarshal(c)
if err != nil {
return nil, err
}
err = s.accts.UpdateMockListing(
c,
accounts.MockListing{
AccountShopListingIDs: accounts.NewAccountIDs(acctID).
ShopID(platform, shopID).
ListingID(listingID),
Name: name,
SKU: sku,
Description: description,
Count: int64(count),
},
)
if err != nil {
return nil, fmt.Errorf("failed to update listing: %w", err)
}
return response.StatusOK(), nil
}
// DELETE /:acctID/platforms/:platform/shops/mocks/:shop-id/listings/:listing-id
func (s *accountSubrouter) deleteMockListing(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
listingID string
)
err := param.Path("platform", param.Platform(&platform)).
Path("shop-id", param.Text(&shopID)).
Path("listing-id", param.Text(&listingID)).
Unmarshal(c)
if err != nil {
return nil, err
}
err = s.accts.DeleteMockListing(
c,
accounts.NewAccountIDs(acctID).
ShopID(platform, shopID).
ListingID(listingID),
)
if err != nil {
return nil, fmt.Errorf("failed to delete listing: %w", err)
}
return response.StatusNoContent(), nil
}
// POST /:acctID/inventory/sync-groups/mock/draft/listings
func (s *accountSubrouter) createMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
acctID := auth.GetIdentity(ctx).Account.AccountID
if _, err := s.accts.CreateMockSyncGroupListingDraft(ctx, acctID); err != nil {
return nil, response.Errorf("failed to create new listing draft: %w", err)
}
return response.StatusCreated(), nil
}
// PUT /:acctID/inventory/sync-groups/mock/draft/listings/:orderIndex/shop
// @platform string
// @shopID string
func (s *accountSubrouter) setShopInMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
acctID := auth.GetIdentity(ctx).Account.AccountID
var (
orderIndex int
platform accounts.Platform
shopID string
)
err := param.Path("orderIndex", param.Int(&orderIndex)).
Form("platform", param.Platform(&platform)).
Form("shop-id", param.Text(&shopID)).
Unmarshal(c)
if err != nil {
return nil, err
}
if err := s.accts.SetShopInMockSyncGroupListingDraft(ctx, acctID, orderIndex, platform, shopID); err != nil {
return nil, response.Errorf("failed to set shop: %w", response.ErrorFromConstant(err))
}
return response.StatusOK(), nil
}
// PUT /:acctID/inventory/sync-groups/mock/draft/listings/:orderIndex/listing
func (s *accountSubrouter) setListingInMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
acctID := auth.GetIdentity(ctx).Account.AccountID
var (
orderIndex int
listingID string
)
err := param.Path("orderIndex", param.Int(&orderIndex)).
Form("listing-id", param.Text(&listingID)).
Unmarshal(c)
if err != nil {
return nil, err
}
if err := s.accts.SetListingInMockSyncGroupListingDraft(ctx, acctID, orderIndex, listingID); err != nil {
return nil, response.Errorf("failed to set listing: %w", response.ErrorFromConstant(err))
}
return response.StatusOK(), nil
}
// DELETE /:acctID/inventory/sync-groups/mock/draft/listings/:orderIndex
func (s *accountSubrouter) deleteMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
acctID := auth.GetIdentity(ctx).Account.AccountID
var (
orderIndex int
)
err := param.Path("orderIndex", param.Int(&orderIndex)).
Unmarshal(c)
if err != nil {
return nil, err
}
if err := s.accts.DeleteMockSyncGroupListingDraft(ctx, acctID, orderIndex); err != nil {
return nil, response.Errorf("failed to delete listing: %w", response.ErrorFromConstant(err))
}
return response.StatusOK(), nil
}
func lowerSnakeCase(s accounts.Platform) string {
return strings.ToLower(strings.Join(strings.Split(string(s), " "), "_"))
}
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"github.com/gin-gonic/gin"
"ruben/inventory2/domains/accounts"
etsy_platform "ruben/inventory2/domains/platforms/etsy"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/logging"
accounts_api "ruben/inventory2/server/api/accounts"
auth_api "ruben/inventory2/server/api/auth"
sse_api "ruben/inventory2/server/api/sse"
"ruben/inventory2/server/api/webhooks"
etsy_webhooks "ruben/inventory2/server/api/webhooks/etsy"
"ruben/inventory2/server/auth"
"ruben/inventory2/server/sse"
)
func Routes(
r *gin.RouterGroup,
logger *logging.Logger,
auth *auth.Auth,
sq *sse.Queue,
accts *accounts.Store,
unp *sse.UpdateNotificationPublisher,
rawEvents *raw_events.Store,
etsy *etsy_platform.Platform,
) {
auth_api.Routes(
r.Group("/auth"),
logger.WithGroup("/auth"),
auth.GetAuthenticator(),
)
sse_api.Routes(
r.Group("/events", auth.Authenticate()),
logger.WithGroup("/events"),
sq,
)
accounts_api.Routes(
r.Group("/accounts", auth.Authenticate()),
logger.WithGroup("/accounts"),
accts,
unp.Group("/accounts"),
)
webhooks.Routes(
r.Group("/webhooks"),
logger.WithGroup("/webhooks"),
rawEvents,
etsy,
webhooks.Config{
Etsy: etsy_webhooks.Config{
OAuthRedirectURIWithAcctIDParam: "/oauth/accounts/:acctID/auth_code",
},
},
)
}
+94
View File
@@ -0,0 +1,94 @@
package auth
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
"ruben/inventory2/domains/authentication"
"ruben/inventory2/logging"
"ruben/inventory2/server/cookies"
"ruben/inventory2/server/response"
)
type loginSubrouter struct {
log *logging.Logger
auth *authentication.Authenticator
}
func Routes(
r *gin.RouterGroup,
logger *logging.Logger,
auth *authentication.Authenticator,
) {
ls := &loginSubrouter{
log: logger,
auth: auth,
}
r.GET("/login", response.Handler(ls.loginPage))
r.GET("/login/callback", response.Handler(ls.loginCallback))
r.GET("/logout", response.Handler(ls.logoutPage))
}
func (s *loginSubrouter) loginPage(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
u, err := NewLoginURL(ctx, s.auth, "/")
if err != nil {
return nil, err
}
return response.TemporaryRedirect(u), nil
}
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)
}
base64EncodedState := fmt.Sprintf("%x", state[:])
return auth.AuthCodeURL(base64EncodedState), nil
}
func (s *loginSubrouter) loginCallback(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
q := r.URL.Query()
// obtain token and profile
accessToken, targetURI, expiration, err := s.auth.Exchange(ctx, q.Get("state"), q.Get("code"))
if err != nil {
return nil, response.Unauthorized().
Msg(fmt.Sprintf("Failed to exchange an authorization code for a token")).
Wrap(err)
}
// set access_token cookie and redirect to a reasonable place
return response.TemporaryRedirect(targetURI).
Cookie(cookies.AccessToken(accessToken, expiration)), nil
}
func (s *loginSubrouter) logoutPage(c *gin.Context) (response.Response, error) {
r := c.Request
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
if ck, err := r.Cookie("access_token"); err == nil && ck != nil {
if err := s.auth.DeleteOAuthTokens(r.Context(), ck.Value); err != nil {
s.log.Error("failed to delete auth token", "error", err)
}
}
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
Cookie(cookies.Expired("access_token")), nil
}
+118
View File
@@ -0,0 +1,118 @@
package sse
import (
"context"
"sync"
"github.com/gin-gonic/gin"
"ruben/inventory2/logging"
"ruben/inventory2/server/auth"
"ruben/inventory2/server/response"
"ruben/inventory2/server/sse"
)
const (
maxNumOpenConnectionsPerUser = 3
)
type (
sseRouter struct {
log *logging.Logger
sse *sse.Queue
users map[string][maxNumOpenConnectionsPerUser]context.CancelFunc
lock sync.Mutex
}
)
func Routes(
r gin.IRouter,
logger *logging.Logger,
sq *sse.Queue,
) {
s := &sseRouter{
log: logger,
sse: sq,
users: make(map[string][maxNumOpenConnectionsPerUser]context.CancelFunc),
}
r.GET("/", response.Handler(s.serveEvents))
}
func (r *sseRouter) serveEvents(c *gin.Context) (response.Response, error) {
acct := auth.GetIdentity(c).Account
acctID := acct.AccountID
userID := acct.UserID
email := acct.Email
log := r.log.WithGroup("serveEvents").With(
"accountID", acctID,
"userID", userID,
"email", email,
"userAgent", c.Request.UserAgent(),
)
log.Info("user connected to sse queue")
ctx := r.closeOutstandingConnectionsForUserAndStoreCancelFuncForUser(c, userID)
w := c.Writer
hdr := w.Header()
hdr.Set("Access-Control-Allow-Origin", "*")
hdr.Set("Access-Control-Expose-Headers", "Content-Type")
hdr.Set("Content-Type", "text/event-stream")
hdr.Set("Connection", "keep-alive")
hdr.Set("Cache-Control", "no-cache")
w.Flush()
err := r.sse.Listen(ctx, acctID, func(ctx context.Context, e *sse.Event) error {
log.Debugf("sending event of type %s", e.Type)
e.Write(w)
return nil
})
if err != nil {
log.Errorf("no longer connected to sse queue due to error: %v", err)
return nil, err
}
// send a 'close' message so the front end doesn't try to reconnect
(&sse.Event{Type: "close"}).Write(w)
log.Info("user disconnecting sse queue")
return response.Status(200), nil
}
func (r *sseRouter) closeOutstandingConnectionsForUserAndStoreCancelFuncForUser(ctx context.Context, userID string) context.Context {
r.lock.Lock()
defer r.lock.Unlock()
ctx, cancel := context.WithCancel(ctx)
closeConnFuncs := r.users[userID]
defer func() {
r.users[userID] = closeConnFuncs
}()
// close existing connection
for i := range maxNumOpenConnectionsPerUser {
if fn := closeConnFuncs[i]; fn == nil {
// not hit limit on connections.
// save the cancellation func and done
closeConnFuncs[i] = cancel
return ctx
}
}
// close the oldest connection, shift all cancellation funcs down, and push the new one in
closeConnFuncs[0]()
for i := range maxNumOpenConnectionsPerUser - 1 {
closeConnFuncs[i] = closeConnFuncs[i+1]
}
closeConnFuncs[maxNumOpenConnectionsPerUser-1] = cancel
return ctx
}
+152
View File
@@ -0,0 +1,152 @@
package etsy
import (
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/gin-gonic/gin"
"ruben/inventory2/domains/platforms/etsy"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/logging"
"ruben/inventory2/server/response"
)
type (
Config struct {
OAuthRedirectURIWithAcctIDParam string
}
)
func Routes(
r *gin.RouterGroup,
logger *logging.Logger,
db *raw_events.Store,
platform *etsy.Platform,
cfg Config,
) {
h := webhooks{
log: logger,
cfg: cfg,
db: db,
etsy: platform,
}
r.POST("/test", response.Handler(h.test))
r.GET(h.cfg.OAuthRedirectURIWithAcctIDParam, response.Handler(h.redirectURI))
r.GET("/:acctID/new-account-link", response.Handler(h.newAccountLink))
}
type (
webhooks struct {
log *logging.Logger
cfg Config
db *raw_events.Store
etsy *etsy.Platform
}
)
// POST /test
func (h webhooks) test(c *gin.Context) (response.Response, error) {
r := c.Request
var body json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return nil, response.Errorf("failed to decode body as json: %w", err)
}
ts := time.Now().UTC()
storeID := "test-store-id"
var payloadObject struct {
StoreID string
}
if err := json.Unmarshal(body, &payloadObject); err == nil && payloadObject.StoreID != "" {
storeID = payloadObject.StoreID
}
err := h.db.Save(r.Context(), &raw_events.Event{
Platform: "etsy",
StoreID: storeID,
EventID: fmt.Sprint(ts.Unix()),
EventTimestamp: ts,
Payload: body,
})
if err != nil {
return nil, response.Errorf("error occurred saving the body as the event payload: %w", err)
}
return response.Status(201), nil
}
// GET h.cfg.OAuthRedirectURIWithAcctIDParam
func (h webhooks) redirectURI(c *gin.Context) (response.Response, error) {
r := c.Request
// get account id for the request
acctID, err := strconv.ParseInt(c.Param("acctID"), 10, 64)
if err != nil || acctID <= 0 {
return nil, response.NotFound()
}
ctx := r.Context()
q := r.URL.Query()
state := q.Get("state")
// handle failed, potentially non-consenting, request
if errCode := q.Get("error"); errCode != "" {
errDesc := q.Get("error_description")
errURI := q.Get("error_uri")
fmt.Printf(
"error in obtaining an OAuth Token: error=%s, error_desc=%s, error_uri=%s, account_id=%d\n",
errCode,
errDesc,
errURI,
acctID,
)
h.etsy.InvalidateState(ctx, state)
return response.Status(200), nil
}
// validate the state to prevent CSRF attacks
ok, err := h.etsy.HandleNewAuthCode(ctx, acctID, state, q.Get("code"))
if err != nil {
h.log.Error("failed to handle new auth code", "error", err)
return nil, response.Forbidden()
}
if !ok {
return nil, response.Forbidden()
}
// redirect to the user's account page
return response.SeeOther(fmt.Sprintf("/accounts/%d", acctID)), nil
}
// GET /:acctID/new-account-link
func (h webhooks) newAccountLink(c *gin.Context) (response.Response, error) {
r := c.Request
acctIDStr := c.Param("acctID")
acctID, err := strconv.ParseInt(acctIDStr, 10, 64)
if err != nil {
return nil, response.NotFound().Msgf("account %s not found", acctIDStr)
}
u, err := h.etsy.GenerateConnectionURLForNewAccount(r.Context(), acctID)
if err != nil {
return nil, fmt.Errorf("failed to generate url for account %d: %w", acctID, err)
}
return response.TemporaryRedirect(u.String()), nil
}
+43
View File
@@ -0,0 +1,43 @@
package tiktok
import (
"encoding/json"
"fmt"
"time"
"github.com/gin-gonic/gin"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/logging"
"ruben/inventory2/server/response"
)
func Routes(
r *gin.RouterGroup,
logger *logging.Logger,
db *raw_events.Store,
) {
r.POST("/test", response.Handler(func(c *gin.Context) (response.Response, error) {
r := c.Request
var body json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("failed to decode body as json: %w", err)
}
ts := time.Now().UTC()
err := db.Save(r.Context(), &raw_events.Event{
Platform: "tiktok",
StoreID: "test-store-1",
EventID: fmt.Sprint(ts.Unix()),
EventTimestamp: ts,
Payload: body,
})
if err != nil {
return nil, fmt.Errorf("error occurred saving the body as the event payload: %w", err)
}
return response.Status(201), nil
}))
}
+42
View File
@@ -0,0 +1,42 @@
package webhooks
import (
"github.com/gin-gonic/gin"
etsy_platform "ruben/inventory2/domains/platforms/etsy"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/logging"
"ruben/inventory2/server/api/webhooks/etsy"
"ruben/inventory2/server/api/webhooks/tiktok"
"ruben/inventory2/server/api/webhooks/wix"
)
type Config struct {
Etsy etsy.Config
}
func Routes(
r *gin.RouterGroup,
logger *logging.Logger,
eventsDB *raw_events.Store,
etsyPlatform *etsy_platform.Platform,
cfg Config,
) {
etsy.Routes(
r.Group("/etsy"),
logger.WithGroup("etsy"),
eventsDB,
etsyPlatform,
cfg.Etsy,
)
tiktok.Routes(
r.Group("/tiktok"),
logger.WithGroup("tiktok"),
eventsDB,
)
wix.Routes(
r.Group("/wix"),
logger.WithGroup("wix"),
eventsDB,
)
}
+43
View File
@@ -0,0 +1,43 @@
package wix
import (
"encoding/json"
"fmt"
"time"
"github.com/gin-gonic/gin"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/logging"
"ruben/inventory2/server/response"
)
func Routes(
r *gin.RouterGroup,
logger *logging.Logger,
db *raw_events.Store,
) {
r.POST("/test", response.Handler(func(c *gin.Context) (response.Response, error) {
r := c.Request
var body json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return nil, fmt.Errorf("failed to decode body as json: %w", err)
}
ts := time.Now().UTC()
err := db.Save(r.Context(), &raw_events.Event{
Platform: "wix",
StoreID: "test-store-1",
EventID: fmt.Sprint(ts.Unix()),
EventTimestamp: ts,
Payload: body,
})
if err != nil {
return nil, fmt.Errorf("error occurred saving the body as the event payload: %w", err)
}
return response.Status(201), nil
}))
}
+191
View File
@@ -0,0 +1,191 @@
package auth
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"time"
"github.com/gin-gonic/gin"
"ruben/inventory2/consts"
"ruben/inventory2/domains/accounts"
"ruben/inventory2/domains/authentication"
"ruben/inventory2/logging"
"ruben/inventory2/server/cookies"
"ruben/inventory2/server/response"
)
type (
Auth struct {
log *logging.Logger
auth *authentication.Authenticator
accts *accounts.Store
}
Identity struct {
AccessToken string
Claims authentication.AccessTokenClaims
User accounts.OAuthUser
Account *accounts.Account
}
LoginURLProviderFunc = func(ctx context.Context, auth *authentication.Authenticator, targetURI string) (string, error)
AuthorizationAssertions = response.HandlerFunc
)
func NewAuth(
logger *logging.Logger,
auth *authentication.Authenticator,
accts *accounts.Store,
) *Auth {
return &Auth{
log: logger,
auth: auth,
accts: accts,
}
}
func (a *Auth) GetAuthenticator() *authentication.Authenticator {
return a.auth
}
// Identify will add an Identity to the context that can then be retrieved via GetIdentity.
func (a *Auth) Identify(c *gin.Context) {
if err := a.addIdentity(c); err != nil {
c.Error(err)
c.Abort()
}
}
func (a *Auth) addIdentity(c *gin.Context) error {
r := c.Request
ck, err := r.Cookie("access_token")
if err != nil {
return nil
}
ctx := r.Context()
accessToken := ck.Value
claims, err := a.auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
if err != nil {
if errors.Is(err, consts.ErrNotFound) {
return nil
}
return response.Errorf("failed to load authentication details: %w", err)
}
expiration := claims.Expiration
if expiration.Before(time.Now()) {
return nil
}
user, acct, err := a.accts.GetUserAndAccountByAccessToken(ctx, accessToken)
if err != nil {
return response.Errorf("failed to load user and account defails: %w", err)
}
c.Request = r.WithContext(SetIdentity(c, Identity{
AccessToken: accessToken,
Claims: claims,
User: user,
Account: acct,
}))
return nil
}
// Authenticate should only be used along with and after Identify
// Typically used with response.Handler to make a gin.HandlerFunc.
func (a *Auth) Authenticate(assertions ...AuthorizationAssertions) func(c *gin.Context) {
return response.Handler(a.AuthenticateHandler(assertions...))
}
// See Authenticate.
func (a *Auth) AuthenticateHandler(assertions ...AuthorizationAssertions) func(c *gin.Context) (response.Response, error) {
return func(c *gin.Context) (response.Response, error) {
id, ok := getIdentity(c)
if !ok {
return nil, response.Unauthorized().
HTML([]byte(`
<h1>Unauthorized</h1>
<a href="/">Return to app</a>
`)) // TODO: would be nice to have a better page for this
}
expiration := id.Claims.Expiration
now := time.Now()
// refresh tokens, when the access token is "old enough"
// id token lifetime is 48 hours, allowing a person to use the app everyday comfortably, with wiggle room, without having to log in.
const idTokenLifetime = 48 * time.Hour
if refreshFloor := expiration.Add(-(idTokenLifetime / 4)); refreshFloor.Before(now) {
accessToken, expiration, err := a.auth.RefreshAccessToken(c, id.AccessToken)
if err != nil {
a.log.Warn("failed to refresh access token", "error", err)
return response.TemporaryRedirect("/").
Body(io.NopCloser(bytes.NewBuffer([]byte(fmt.Sprintf("failed to refresh access token: %v", err))))).
Cookie(cookies.Expired("access_token")), nil
}
// 'redirect' to same url, to set the new access_token cookie
return response.TemporaryRedirect(c.Request.URL.String()).
Cookie(cookies.AccessToken(accessToken, expiration)), nil
}
for _, as := range assertions {
if res, err := as(c); res != nil || err != nil {
return res, err
}
}
return nil, nil
}
}
type identityKey struct{}
// stores identity in context
func SetIdentity(ctx context.Context, id Identity) context.Context {
c, ok := ctx.(*gin.Context)
if ok {
c.Set(identityKey{}, id)
ctx = c.Request.Context()
}
return context.WithValue(ctx, identityKey{}, id)
}
// get identity from context
func GetIdentity(ctx context.Context) Identity {
id, _ := getIdentity(ctx)
return id
}
func getIdentity(ctx context.Context) (Identity, bool) {
id, ok := ctx.Value(identityKey{}).(Identity)
if ok {
return id, true
}
c, ok := ctx.(*gin.Context)
if !ok {
return Identity{}, false
}
v, ok := c.Get(identityKey{})
if !ok {
return Identity{}, false
}
id, ok = v.(Identity)
return id, ok
}
+17
View File
@@ -0,0 +1,17 @@
package cookies
import (
"net/http"
"time"
)
func AccessToken(tkn string, expiration time.Time) http.Cookie {
return http.Cookie{
Name: "access_token",
Value: tkn,
Path: "/",
Expires: expiration,
MaxAge: 0, // using Expiration instead
Secure: true,
}
}
+11
View File
@@ -0,0 +1,11 @@
package cookies
import "net/http"
func Expired(name string) http.Cookie {
return http.Cookie{
Name: name,
Path: "/",
MaxAge: -1, // expire the cookie
}
}
+51
View File
@@ -0,0 +1,51 @@
package param
import (
"encoding"
"strconv"
"ruben/inventory2/domains/accounts"
)
// encoding.TextUnmarshaler instances for Spec
func Text(dst *string) encoding.TextUnmarshaler {
return (*rawText)(dst)
}
func Int(dst *int) encoding.TextUnmarshaler {
return (*intText)(dst)
}
func Platform(dst *accounts.Platform) encoding.TextUnmarshaler {
return (*platformText)(dst)
}
type (
rawText string
intText int
platformText accounts.Platform
)
func (t *rawText) UnmarshalText(text []byte) error {
*t = rawText(text)
return nil
}
func (n *intText) UnmarshalText(text []byte) error {
i, err := strconv.Atoi(string(text))
if err != nil {
return err
}
*n = intText(i)
return nil
}
func (p *platformText) UnmarshalText(text []byte) error {
v, err := accounts.NewPlatform(string(text))
if err != nil {
return err
}
*p = platformText(v)
return nil
}
+76
View File
@@ -0,0 +1,76 @@
package param
import (
"encoding"
"github.com/gin-gonic/gin"
"ruben/inventory2/server/response"
)
// Spec is the entry point of the package.
// It's typically constructed via Path() or Form().
// But it's zero value is valid.
//
// It's methods support a builder pattern to minimize API bloat.
//
// To complete gin parameter parsing, call Unmarshal().
type Spec struct {
path map[string]encoding.TextUnmarshaler
form map[string]encoding.TextUnmarshaler
}
func Path(k string, dst encoding.TextUnmarshaler) Spec {
return Spec{
path: map[string]encoding.TextUnmarshaler{
k: dst,
},
}
}
func Form(k string, dst encoding.TextUnmarshaler) Spec {
return Spec{
form: map[string]encoding.TextUnmarshaler{
k: dst,
},
}
}
func (s Spec) Path(k string, dst encoding.TextUnmarshaler) Spec {
if s.path == nil {
s.path = make(map[string]encoding.TextUnmarshaler, 1)
}
s.path[k] = dst
return s
}
func (s Spec) Form(k string, dst encoding.TextUnmarshaler) Spec {
if s.form == nil {
s.form = make(map[string]encoding.TextUnmarshaler, 1)
}
s.form[k] = dst
return s
}
func (s Spec) Unmarshal(c *gin.Context) error {
for k, dst := range s.path {
v := c.Param(k)
if v == "" {
return response.NotFound().Msgf("no %s provided", k)
}
if err := dst.UnmarshalText([]byte(v)); err != nil {
return response.NotFound().Wrap(err).Msgf("invalid %s", k)
}
}
for k, dst := range s.form {
v, ok := c.GetPostForm(k)
if !ok || v == "" {
return response.BadRequest().Msgf("no %s provided", k)
}
if err := dst.UnmarshalText([]byte(v)); err != nil {
return response.BadRequest().Wrap(err).Msgf("invalid %s provided", k)
}
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
package redirect
import "net/http"
type (
Code int
)
var (
MovedPermanently = Code(http.StatusMovedPermanently)
Found = Code(http.StatusFound)
SeeOther = Code(http.StatusSeeOther)
TemporaryRedirect = Code(http.StatusTemporaryRedirect)
PermanentRedirect = Code(http.StatusPermanentRedirect)
)
func (c Code) Int() int {
return int(c)
}
+152
View File
@@ -0,0 +1,152 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/server/redirect"
)
type (
bodyRes struct {
body io.ReadCloser
res Response
}
)
var _ Response = bodyRes{}
func Body(body io.ReadCloser) Response {
return bodyRes{
body: body,
}
}
func (b bodyRes) String() string {
if b.res != nil {
return fmt.Sprintf(`{"body": %q, "nested": %s}`, b.body, b.res)
}
return fmt.Sprintf(`{"body": %q}`, b.body)
}
func (b bodyRes) wrap(res Response) Response {
b.res = res
return b
}
func (b bodyRes) Status(code int) Response {
return Status(code).wrap(b)
}
func (b bodyRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(b)
}
func (b bodyRes) HTML(body []byte) Response {
return HTML(body).wrap(b)
}
func (b bodyRes) JSON(body any) Response {
return JSON(body).wrap(b)
}
func (b bodyRes) Body(body io.ReadCloser) Response {
b.body = body
return b
}
func (b bodyRes) Cookie(ck http.Cookie) Response {
return Cookie(ck).wrap(b)
}
func (b bodyRes) HXPushURL(v string) Response {
return HXPushURL(v).wrap(b)
}
func (b bodyRes) HXLocation(v string) Response {
return HXLocation(v).wrap(b)
}
func (b bodyRes) HXPushUrl(v string) Response {
return HXPushUrl(v).wrap(b)
}
func (b bodyRes) HXRedirect(v string) Response {
return HXRedirect(v).wrap(b)
}
func (b bodyRes) HXRefresh(v string) Response {
return HXRefresh(v).wrap(b)
}
func (b bodyRes) HXReplaceUrl(v string) Response {
return HXReplaceUrl(v).wrap(b)
}
func (b bodyRes) HXReswap(v string) Response {
return HXReswap(v).wrap(b)
}
func (b bodyRes) HXRetarget(v string) Response {
return HXRetarget(v).wrap(b)
}
func (b bodyRes) HXReselect(v string) Response {
return HXReselect(v).wrap(b)
}
func (b bodyRes) HXTrigger(v string) Response {
return HXTrigger(v).wrap(b)
}
func (b bodyRes) HXTriggerAfterSettle(v string) Response {
return HXTriggerAfterSettle(v).wrap(b)
}
func (b bodyRes) HXTriggerAfterSwap(v string) Response {
return HXTriggerAfterSwap(v).wrap(b)
}
func (b bodyRes) GetStatus() (int, bool) {
if b.res == nil {
return 0, false
}
return b.res.GetStatus()
}
func (b bodyRes) getHeaders() [][2]string {
if b.res != nil {
return b.res.getHeaders()
}
return nil
}
func (b bodyRes) GetRedirect() (code redirect.Code, to string, ok bool) {
if b.res == nil {
return 0, "", false
}
return b.res.GetRedirect()
}
func (b bodyRes) getBody() (body io.ReadCloser, ok bool, err error) {
return b.body, true, nil
}
func (b bodyRes) getCookies() []http.Cookie {
if b.res != nil {
return b.res.getCookies()
}
return nil
}
func (b bodyRes) getContentType() (contentType string, ok bool) {
if b.res != nil {
return b.res.getContentType()
}
return "", false
}
+155
View File
@@ -0,0 +1,155 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/server/redirect"
)
type (
cookieRes struct {
cookie http.Cookie
res Response
}
)
var _ Response = cookieRes{}
func Cookie(c http.Cookie) Response {
return cookieRes{
cookie: c,
}
}
func (c cookieRes) String() string {
if c.res != nil {
return fmt.Sprintf(`{"cookie": %q, "nested": %s}`, &c.cookie, c.res)
}
return fmt.Sprintf(`{"cookie": %q}`, &c.cookie)
}
func (c cookieRes) wrap(res Response) Response {
c.res = res
return c
}
func (c cookieRes) Status(code int) Response {
return Status(code).wrap(c)
}
func (c cookieRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(c)
}
func (c cookieRes) HTML(body []byte) Response {
return HTML(body).wrap(c)
}
func (c cookieRes) JSON(body any) Response {
return JSON(body).wrap(c)
}
func (c cookieRes) Body(body io.ReadCloser) Response {
return Body(body).wrap(c)
}
func (c cookieRes) Cookie(ck http.Cookie) Response {
return Cookie(ck).wrap(c)
}
func (c cookieRes) HXPushURL(v string) Response {
return HXPushURL(v).wrap(c)
}
func (c cookieRes) HXLocation(v string) Response {
return HXLocation(v).wrap(c)
}
func (c cookieRes) HXPushUrl(v string) Response {
return HXPushUrl(v).wrap(c)
}
func (c cookieRes) HXRedirect(v string) Response {
return HXRedirect(v).wrap(c)
}
func (c cookieRes) HXRefresh(v string) Response {
return HXRefresh(v).wrap(c)
}
func (c cookieRes) HXReplaceUrl(v string) Response {
return HXReplaceUrl(v).wrap(c)
}
func (c cookieRes) HXReswap(v string) Response {
return HXReswap(v).wrap(c)
}
func (c cookieRes) HXRetarget(v string) Response {
return HXRetarget(v).wrap(c)
}
func (c cookieRes) HXReselect(v string) Response {
return HXReselect(v).wrap(c)
}
func (c cookieRes) HXTrigger(v string) Response {
return HXTrigger(v).wrap(c)
}
func (c cookieRes) HXTriggerAfterSettle(v string) Response {
return HXTriggerAfterSettle(v).wrap(c)
}
func (c cookieRes) HXTriggerAfterSwap(v string) Response {
return HXTriggerAfterSwap(v).wrap(c)
}
func (c cookieRes) GetStatus() (int, bool) {
if c.res == nil {
return 0, false
}
return c.res.GetStatus()
}
func (c cookieRes) getHeaders() [][2]string {
if c.res != nil {
return c.res.getHeaders()
}
return nil
}
func (c cookieRes) GetRedirect() (code redirect.Code, to string, ok bool) {
if c.res == nil {
return 0, "", false
}
return c.res.GetRedirect()
}
func (c cookieRes) getBody() (body io.ReadCloser, ok bool, err error) {
if c.res == nil {
return nil, false, nil
}
return c.res.getBody()
}
func (c cookieRes) getCookies() []http.Cookie {
if c.res != nil {
return append(c.res.getCookies(), c.cookie)
}
return []http.Cookie{c.cookie}
}
func (c cookieRes) getContentType() (contentType string, ok bool) {
if c.res != nil {
return c.res.getContentType()
}
return "", false
}
+186
View File
@@ -0,0 +1,186 @@
package response
import (
"errors"
"fmt"
"net/http"
"strings"
"ruben/inventory2/consts"
)
type (
// ErrorResponse is an error
ErrorResponse struct {
err error
msg string
status int
html []byte
}
)
// Constructors
func Errorf(format string, args ...any) ErrorResponse {
return ErrorResponse{
err: fmt.Errorf(format, args...),
}
}
func BadRequest() ErrorResponse {
return ErrorResponse{
status: http.StatusBadRequest,
}
}
func NotFound() ErrorResponse {
return ErrorResponse{
status: http.StatusNotFound,
}
}
func Unauthorized() ErrorResponse {
return ErrorResponse{
status: http.StatusUnauthorized,
}
}
func Forbidden() ErrorResponse {
return ErrorResponse{
status: http.StatusForbidden,
}
}
func Conflict() ErrorResponse {
return ErrorResponse{
status: http.StatusConflict,
}
}
// builder pattern implementation
func (e ErrorResponse) Msg(msg string) ErrorResponse {
e.msg = msg
return e
}
func (e ErrorResponse) Msgf(format string, args ...any) ErrorResponse {
e.msg = fmt.Sprintf(format, args...)
return e
}
func (e ErrorResponse) Status(status int) ErrorResponse {
e.status = status
return e
}
func (e ErrorResponse) Wrap(err error) ErrorResponse {
e.err = err
return e
}
func (e ErrorResponse) HTML(h []byte) ErrorResponse {
e.html = h
return e
}
// error implementation
func (e ErrorResponse) Error() string {
parts := make([]string, 0, 3)
if e.msg != "" {
parts = append(parts, e.msg)
} else if e.status != 0 {
parts = append(parts, fmt.Sprintf("status = %d", e.status))
}
if e.err != nil {
parts = append(parts, e.err.Error())
}
if len(parts) == 0 {
return "status = 500"
}
return strings.Join(parts, ": ")
}
func (e ErrorResponse) Unwrap() error {
return e.err
}
// nested response value resolution
func (e ErrorResponse) GetStatus() (int, bool) {
if e.status != 0 {
return e.status, true
}
ce, ok := GetError(e.err)
if ok {
return ce.GetStatus()
}
return 0, false
}
func (e ErrorResponse) GetMsg() (string, bool) {
if e.msg != "" {
return e.msg, true
}
ce, ok := GetError(e.err)
if ok {
return ce.GetMsg()
}
return "", false
}
func (e ErrorResponse) GetHTML() ([]byte, bool) {
if len(e.html) != 0 {
return e.html, true
}
ce, ok := GetError(e.err)
if ok {
return ce.GetHTML()
}
return nil, false
}
func GetError(err error) (e ErrorResponse, ok bool) {
if ok = errors.As(err, &e); ok {
return e, true
}
var ptr *ErrorResponse
if ok = errors.As(err, &ptr); ok {
return *ptr, true
}
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
}
+26
View File
@@ -0,0 +1,26 @@
package response
import (
"github.com/gin-gonic/gin"
)
type (
HandlerFunc = func(c *gin.Context) (Response, error)
Middleware = func(HandlerFunc) HandlerFunc
responseKey struct{}
)
func Handler(f HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
res, err := f(c)
if err != nil {
c.Error(err)
c.Abort()
} else if res != nil {
c.Set(responseKey{}, res)
c.Abort()
}
}
}
+213
View File
@@ -0,0 +1,213 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/server/redirect"
)
type (
headerRes struct {
name string
value string
res Response
}
)
var _ Response = headerRes{}
func header(name, value string) Response {
return headerRes{
name: name,
value: value,
}
}
func HXPushURL(v string) Response {
return header("HX-Push-Url", v)
}
func HXLocation(v string) Response {
return header("HX-Location", v)
}
func HXPushUrl(v string) Response {
return header("HX-Push-Url", v)
}
func HXRedirect(v string) Response {
return header("HX-Redirect", v)
}
func HXRefresh(v string) Response {
return header("HX-Refresh", v)
}
func HXReplaceUrl(v string) Response {
return header("HX-Replace-Url", v)
}
func HXReswap(v string) Response {
return header("HX-Reswap", v)
}
func HXRetarget(v string) Response {
return header("HX-Retarget", v)
}
func HXReselect(v string) Response {
return header("HX-Reselect", v)
}
func HXTrigger(v string) Response {
return header("HX-Trigger", v)
}
func HXTriggerAfterSettle(v string) Response {
return header("HX-Trigger-After-Settle", v)
}
func HXTriggerAfterSwap(v string) Response {
return header("HX-Trigger-After-Swap", v)
}
func (h headerRes) String() string {
if h.res != nil {
return fmt.Sprintf(`{"header": {"name": %q, "value": %q}, "nested": %s}`, h.name, h.value, h.res)
}
return fmt.Sprintf(`{"header": {"name": %q, "value": %q}}`, h.name, h.value)
}
func (h headerRes) wrap(res Response) Response {
h.res = res
return h
}
func (h headerRes) Status(code int) Response {
return Status(code).wrap(h)
}
func (h headerRes) Header(name, value string) Response {
h.name = name
h.value = value
return h
}
func (h headerRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(h)
}
func (h headerRes) HTML(body []byte) Response {
return HTML(body).wrap(h)
}
func (h headerRes) JSON(body any) Response {
return JSON(body).wrap(h)
}
func (h headerRes) Body(body io.ReadCloser) Response {
return Body(body).wrap(h)
}
func (h headerRes) Cookie(ck http.Cookie) Response {
return Cookie(ck).wrap(h)
}
func (h headerRes) HXPushURL(v string) Response {
return HXPushURL(v).wrap(h)
}
func (h headerRes) HXLocation(v string) Response {
return HXLocation(v).wrap(h)
}
func (h headerRes) HXPushUrl(v string) Response {
return HXPushUrl(v).wrap(h)
}
func (h headerRes) HXRedirect(v string) Response {
return HXRedirect(v).wrap(h)
}
func (h headerRes) HXRefresh(v string) Response {
return HXRefresh(v).wrap(h)
}
func (h headerRes) HXReplaceUrl(v string) Response {
return HXReplaceUrl(v).wrap(h)
}
func (h headerRes) HXReswap(v string) Response {
return HXReswap(v).wrap(h)
}
func (h headerRes) HXRetarget(v string) Response {
return HXRetarget(v).wrap(h)
}
func (h headerRes) HXReselect(v string) Response {
return HXReselect(v).wrap(h)
}
func (h headerRes) HXTrigger(v string) Response {
return HXTrigger(v).wrap(h)
}
func (h headerRes) HXTriggerAfterSettle(v string) Response {
return HXTriggerAfterSettle(v).wrap(h)
}
func (h headerRes) HXTriggerAfterSwap(v string) Response {
return HXTriggerAfterSwap(v).wrap(h)
}
func (h headerRes) GetStatus() (int, bool) {
if h.res == nil {
return 0, false
}
return h.res.GetStatus()
}
func (h headerRes) getHeaders() [][2]string {
hdr := [2]string{h.name, h.value}
if h.res == nil {
return [][2]string{hdr}
}
return append(h.res.getHeaders(), hdr)
}
func (h headerRes) GetRedirect() (code redirect.Code, to string, ok bool) {
if h.res == nil {
return 0, "", false
}
return h.res.GetRedirect()
}
func (h headerRes) getBody() (body io.ReadCloser, ok bool, err error) {
if h.res == nil {
return nil, false, nil
}
return h.res.getBody()
}
func (h headerRes) getCookies() []http.Cookie {
if h.res != nil {
return h.res.getCookies()
}
return nil
}
func (h headerRes) getContentType() (contentType string, ok bool) {
if h.res != nil {
return h.res.getContentType()
}
return "", false
}
+149
View File
@@ -0,0 +1,149 @@
package response
import (
"bytes"
"fmt"
"io"
"net/http"
"ruben/inventory2/server/redirect"
)
type (
htmlRes struct {
body []byte
res Response
}
)
var _ Response = htmlRes{}
func HTML(body []byte) Response {
return htmlRes{
body: body,
}
}
func (h htmlRes) String() string {
if h.res != nil {
return fmt.Sprintf(`{"body": %q, "nested": %s}`, string(h.body), h.res)
}
return fmt.Sprintf(`{"body": %q}`, h.body)
}
func (h htmlRes) wrap(res Response) Response {
h.res = res
return h
}
func (h htmlRes) Status(code int) Response {
return Status(code).wrap(h)
}
func (h htmlRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(h)
}
func (h htmlRes) HTML(body []byte) Response {
h.body = body
return h
}
func (h htmlRes) JSON(body any) Response {
return JSON(body).wrap(h)
}
func (h htmlRes) Body(body io.ReadCloser) Response {
return Body(body).wrap(h)
}
func (h htmlRes) Cookie(ck http.Cookie) Response {
return Cookie(ck).wrap(h)
}
func (h htmlRes) HXPushURL(v string) Response {
return HXPushURL(v).wrap(h)
}
func (h htmlRes) HXLocation(v string) Response {
return HXLocation(v).wrap(h)
}
func (h htmlRes) HXPushUrl(v string) Response {
return HXPushUrl(v).wrap(h)
}
func (h htmlRes) HXRedirect(v string) Response {
return HXRedirect(v).wrap(h)
}
func (h htmlRes) HXRefresh(v string) Response {
return HXRefresh(v).wrap(h)
}
func (h htmlRes) HXReplaceUrl(v string) Response {
return HXReplaceUrl(v).wrap(h)
}
func (h htmlRes) HXReswap(v string) Response {
return HXReswap(v).wrap(h)
}
func (h htmlRes) HXRetarget(v string) Response {
return HXRetarget(v).wrap(h)
}
func (h htmlRes) HXReselect(v string) Response {
return HXReselect(v).wrap(h)
}
func (h htmlRes) HXTrigger(v string) Response {
return HXTrigger(v).wrap(h)
}
func (h htmlRes) HXTriggerAfterSettle(v string) Response {
return HXTriggerAfterSettle(v).wrap(h)
}
func (h htmlRes) HXTriggerAfterSwap(v string) Response {
return HXTriggerAfterSwap(v).wrap(h)
}
func (h htmlRes) GetStatus() (int, bool) {
if h.res == nil {
return 0, false
}
return h.res.GetStatus()
}
func (h htmlRes) getHeaders() [][2]string {
if h.res != nil {
return h.res.getHeaders()
}
return nil
}
func (h htmlRes) GetRedirect() (code redirect.Code, to string, ok bool) {
if h.res == nil {
return 0, "", false
}
return h.res.GetRedirect()
}
func (h htmlRes) getBody() (body io.ReadCloser, ok bool, err error) {
return io.NopCloser(bytes.NewBuffer(h.body)), true, nil
}
func (h htmlRes) getCookies() []http.Cookie {
if h.res != nil {
return h.res.getCookies()
}
return nil
}
func (h htmlRes) getContentType() (contentType string, ok bool) {
return "text/html", true
}
+151
View File
@@ -0,0 +1,151 @@
package response
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"ruben/inventory2/server/redirect"
)
type (
jsonRes struct {
body any
res Response
}
)
var _ Response = jsonRes{}
func JSON(body any) Response {
return jsonRes{
body: body,
}
}
func (j jsonRes) String() string {
if j.res != nil {
return fmt.Sprintf(`{"body": %q, "nested": %s}`, j.body, j.res)
}
return fmt.Sprintf(`{"body": %q}`, j.body)
}
func (j jsonRes) wrap(res Response) Response {
j.res = res
return j
}
func (j jsonRes) Status(code int) Response {
return Status(code).wrap(j)
}
func (j jsonRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(j)
}
func (j jsonRes) HTML(body []byte) Response {
return HTML(body).wrap(j)
}
func (j jsonRes) JSON(body any) Response {
j.body = body
return j
}
func (j jsonRes) Body(body io.ReadCloser) Response {
return Body(body).wrap(j)
}
func (j jsonRes) Cookie(ck http.Cookie) Response {
return Cookie(ck).wrap(j)
}
func (j jsonRes) HXPushURL(v string) Response {
return HXPushURL(v).wrap(j)
}
func (j jsonRes) HXLocation(v string) Response {
return HXLocation(v).wrap(j)
}
func (j jsonRes) HXPushUrl(v string) Response {
return HXPushUrl(v).wrap(j)
}
func (j jsonRes) HXRedirect(v string) Response {
return HXRedirect(v).wrap(j)
}
func (j jsonRes) HXRefresh(v string) Response {
return HXRefresh(v).wrap(j)
}
func (j jsonRes) HXReplaceUrl(v string) Response {
return HXReplaceUrl(v).wrap(j)
}
func (j jsonRes) HXReswap(v string) Response {
return HXReswap(v).wrap(j)
}
func (j jsonRes) HXRetarget(v string) Response {
return HXRetarget(v).wrap(j)
}
func (j jsonRes) HXReselect(v string) Response {
return HXReselect(v).wrap(j)
}
func (j jsonRes) HXTrigger(v string) Response {
return HXTrigger(v).wrap(j)
}
func (j jsonRes) HXTriggerAfterSettle(v string) Response {
return HXTriggerAfterSettle(v).wrap(j)
}
func (j jsonRes) HXTriggerAfterSwap(v string) Response {
return HXTriggerAfterSwap(v).wrap(j)
}
func (j jsonRes) GetStatus() (int, bool) {
if j.res == nil {
return 0, false
}
return j.res.GetStatus()
}
func (j jsonRes) getHeaders() [][2]string {
if j.res != nil {
return j.res.getHeaders()
}
return nil
}
func (j jsonRes) GetRedirect() (code redirect.Code, to string, ok bool) {
if j.res == nil {
return 0, "", false
}
return j.res.GetRedirect()
}
func (j jsonRes) getBody() (body io.ReadCloser, ok bool, err error) {
buf := new(bytes.Buffer)
return io.NopCloser(buf), true, json.NewEncoder(buf).Encode(j.body)
}
func (j jsonRes) getCookies() []http.Cookie {
if j.res != nil {
return j.res.getCookies()
}
return nil
}
func (j jsonRes) getContentType() (contentType string, ok bool) {
return "application/json", true
}
+192
View File
@@ -0,0 +1,192 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/server/redirect"
)
type (
redirectRes struct {
code redirect.Code
to string
res Response
}
)
var _ Response = redirectRes{}
// convenience constructors
func MovedPermanently(to string) Response {
return redirectRes{
code: redirect.MovedPermanently,
to: to,
}
}
func Found(to string) Response {
return redirectRes{
code: redirect.Found,
to: to,
}
}
func SeeOther(to string) Response {
return redirectRes{
code: redirect.SeeOther,
to: to,
}
}
func TemporaryRedirect(to string) Response {
return redirectRes{
code: redirect.TemporaryRedirect,
to: to,
}
}
func PermanentRedirect(to string) Response {
return redirectRes{
code: redirect.PermanentRedirect,
to: to,
}
}
func Redirect(code redirect.Code, to string) Response {
return redirectRes{
code: code,
to: to,
}
}
func (r redirectRes) String() string {
if r.res != nil {
return fmt.Sprintf(`{"redirect": {"code": %d, "to": %q}, "nested": %s}`, r.code, r.to, r.res)
}
return fmt.Sprintf(`{"redirect": {"code": %d, "to": %q}}`, r.code, r.to)
}
func (r redirectRes) wrap(res Response) Response {
r.res = res
return r
}
func (r redirectRes) Status(code int) Response {
return Status(code).wrap(r)
}
func (r redirectRes) Redirect(code redirect.Code, to string) Response {
r.code = code
r.to = to
return r
}
func (r redirectRes) HTML(body []byte) Response {
return HTML(body).wrap(r)
}
func (r redirectRes) JSON(body any) Response {
return JSON(body).wrap(r)
}
func (r redirectRes) Body(body io.ReadCloser) Response {
return Body(body).wrap(r)
}
func (r redirectRes) Cookie(ck http.Cookie) Response {
return Cookie(ck).wrap(r)
}
func (r redirectRes) HXPushURL(v string) Response {
return HXPushURL(v).wrap(r)
}
func (r redirectRes) HXLocation(v string) Response {
return HXLocation(v).wrap(r)
}
func (r redirectRes) HXPushUrl(v string) Response {
return HXPushUrl(v).wrap(r)
}
func (r redirectRes) HXRedirect(v string) Response {
return HXRedirect(v).wrap(r)
}
func (r redirectRes) HXRefresh(v string) Response {
return HXRefresh(v).wrap(r)
}
func (r redirectRes) HXReplaceUrl(v string) Response {
return HXReplaceUrl(v).wrap(r)
}
func (r redirectRes) HXReswap(v string) Response {
return HXReswap(v).wrap(r)
}
func (r redirectRes) HXRetarget(v string) Response {
return HXRetarget(v).wrap(r)
}
func (r redirectRes) HXReselect(v string) Response {
return HXReselect(v).wrap(r)
}
func (r redirectRes) HXTrigger(v string) Response {
return HXTrigger(v).wrap(r)
}
func (r redirectRes) HXTriggerAfterSettle(v string) Response {
return HXTriggerAfterSettle(v).wrap(r)
}
func (r redirectRes) HXTriggerAfterSwap(v string) Response {
return HXTriggerAfterSwap(v).wrap(r)
}
func (r redirectRes) GetStatus() (int, bool) {
if r.res == nil {
return 0, false
}
return r.res.GetStatus()
}
func (r redirectRes) getHeaders() [][2]string {
if r.res != nil {
return r.res.getHeaders()
}
return nil
}
func (r redirectRes) GetRedirect() (code redirect.Code, to string, ok bool) {
return r.code, r.to, true
}
func (r redirectRes) getBody() (body io.ReadCloser, ok bool, err error) {
if r.res == nil {
return nil, false, nil
}
return r.res.getBody()
}
func (r redirectRes) getCookies() []http.Cookie {
if r.res != nil {
return r.res.getCookies()
}
return nil
}
func (r redirectRes) getContentType() (contentType string, ok bool) {
if r.res != nil {
return r.res.getContentType()
}
return "", false
}
+40
View File
@@ -0,0 +1,40 @@
package response
import (
"io"
"net/http"
"ruben/inventory2/server/redirect"
)
type (
Response interface {
Status(int) Response
HXPushURL(string) Response
HXLocation(string) Response
HXPushUrl(string) Response
HXRedirect(string) Response
HXRefresh(string) Response
HXReplaceUrl(string) Response
HXReswap(string) Response
HXRetarget(string) Response
HXReselect(string) Response
HXTrigger(string) Response
HXTriggerAfterSettle(string) Response
HXTriggerAfterSwap(string) Response
Redirect(code redirect.Code, to string) Response
Body(io.ReadCloser) Response
HTML([]byte) Response
JSON(any) Response
Cookie(http.Cookie) Response
GetStatus() (code int, ok bool)
getBody() (body io.ReadCloser, ok bool, err error)
GetRedirect() (code redirect.Code, to string, ok bool)
getCookies() []http.Cookie
getContentType() (contentType string, ok bool)
getHeaders() [][2]string
wrap(Response) Response
}
)
+168
View File
@@ -0,0 +1,168 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/server/redirect"
)
type (
statusRes struct {
code int
res Response
}
)
var _ Response = statusRes{}
func Status(code int) Response {
return statusRes{
code: code,
}
}
func StatusOK() Response {
return Status(http.StatusOK)
}
func StatusCreated() Response {
return Status(http.StatusCreated)
}
func StatusAccepted() Response {
return Status(http.StatusAccepted)
}
func StatusNoContent() Response {
return Status(http.StatusNoContent)
}
func (s statusRes) String() string {
if s.res != nil {
return fmt.Sprintf(`{"status": %d, "nested": %s}`, s.code, s.res)
}
return fmt.Sprintf(`{"status": %d}`, s.code)
}
func (s statusRes) wrap(res Response) Response {
s.res = res
return s
}
func (s statusRes) Status(code int) Response {
s.code = code
return s
}
func (s statusRes) Redirect(code redirect.Code, to string) Response {
return Redirect(code, to).wrap(s)
}
func (s statusRes) HTML(body []byte) Response {
return HTML(body).wrap(s)
}
func (s statusRes) JSON(body any) Response {
return JSON(body).wrap(s)
}
func (s statusRes) Body(body io.ReadCloser) Response {
return Body(body).wrap(s)
}
func (s statusRes) Cookie(ck http.Cookie) Response {
return Cookie(ck).wrap(s)
}
func (s statusRes) HXPushURL(v string) Response {
return HXPushURL(v).wrap(s)
}
func (s statusRes) HXLocation(v string) Response {
return HXLocation(v).wrap(s)
}
func (s statusRes) HXPushUrl(v string) Response {
return HXPushUrl(v).wrap(s)
}
func (s statusRes) HXRedirect(v string) Response {
return HXRedirect(v).wrap(s)
}
func (s statusRes) HXRefresh(v string) Response {
return HXRefresh(v).wrap(s)
}
func (s statusRes) HXReplaceUrl(v string) Response {
return HXReplaceUrl(v).wrap(s)
}
func (s statusRes) HXReswap(v string) Response {
return HXReswap(v).wrap(s)
}
func (s statusRes) HXRetarget(v string) Response {
return HXRetarget(v).wrap(s)
}
func (s statusRes) HXReselect(v string) Response {
return HXReselect(v).wrap(s)
}
func (s statusRes) HXTrigger(v string) Response {
return HXTrigger(v).wrap(s)
}
func (s statusRes) HXTriggerAfterSettle(v string) Response {
return HXTriggerAfterSettle(v).wrap(s)
}
func (s statusRes) HXTriggerAfterSwap(v string) Response {
return HXTriggerAfterSwap(v).wrap(s)
}
func (s statusRes) GetStatus() (int, bool) {
return s.code, true
}
func (s statusRes) getHeaders() [][2]string {
if s.res != nil {
return s.res.getHeaders()
}
return nil
}
func (s statusRes) GetRedirect() (code redirect.Code, to string, ok bool) {
if s.res == nil {
return 0, "", false
}
return s.res.GetRedirect()
}
func (s statusRes) getBody() (body io.ReadCloser, ok bool, err error) {
if s.res == nil {
return nil, false, nil
}
return s.res.getBody()
}
func (s statusRes) getCookies() []http.Cookie {
if s.res != nil {
return s.res.getCookies()
}
return nil
}
func (s statusRes) getContentType() (contentType string, ok bool) {
if s.res != nil {
return s.res.getContentType()
}
return "", false
}
+168
View File
@@ -0,0 +1,168 @@
package response
import (
"errors"
"fmt"
"io"
"net/http"
"github.com/gin-gonic/gin"
"ruben/inventory2/consts"
)
func HandleResponses(c *gin.Context) {
c.Next()
if len(c.Errors) > 0 || c.Writer.Written() {
return
}
v, ok := c.Get(responseKey{})
if !ok {
return
}
res, ok := v.(Response)
if !ok {
return
}
writeResponse(c, res)
}
func HandleErrors(c *gin.Context) {
c.Next()
if len(c.Errors) == 0 || c.Writer.Written() {
return
}
var (
err ErrorResponse
ok bool
)
for _, e := range c.Errors {
if err, ok = GetError(e); ok {
break
}
}
if !ok {
var (
err error
status int
statusFound bool
)
for _, e := range c.Errors {
err = errors.Join(err, e)
if !statusFound {
status, statusFound = mapErrorConstantsToStatus(e)
}
}
if !statusFound {
status = http.StatusInternalServerError
}
c.String(status, err.Error())
return
}
status, ok := err.GetStatus()
if !ok {
status = http.StatusInternalServerError
}
if h, ok := err.GetHTML(); ok {
c.Status(status)
c.Header("Content-Type", "text/html")
c.Writer.Write(h)
return
}
msg, ok := err.GetMsg()
if !ok {
msg = err.Error()
}
c.String(status, msg)
}
func writeResponse(c *gin.Context, res Response) {
w := c.Writer
// w.Header() must be set before ResponseWriter.WriteHeader is called
// or redirect is attempted
hdrs := w.Header()
for _, hdr := range res.getHeaders() {
hdrs.Add(hdr[0], hdr[1])
}
for _, ck := range res.getCookies() {
hdrs.Add("Set-Cookie", ck.String())
}
if ct, ok := res.getContentType(); ok {
hdrs.Set("Content-Type", ct)
}
if code, to, ok := res.GetRedirect(); ok {
c.Redirect(code.Int(), to)
c.Abort()
return
}
// the body is written after the status header, but it's read here
// first, because if an error is incurred, an error status header will
// need to be written.
body, bodySet, err := res.getBody()
if err != nil {
http.Error(
w,
fmt.Sprintf("Failed to construct response body: %v", err.Error()),
http.StatusInternalServerError,
)
return
}
if status, ok := res.GetStatus(); ok {
w.WriteHeader(status)
}
if bodySet {
// will automatically set the status header,
// if w.WriteHeader wasn't already called
io.Copy(w, body)
}
}
func GetStatusFromError(err error) int {
status := http.StatusInternalServerError
if e, ok := GetError(err); ok {
if status, ok = e.GetStatus(); !ok {
status = http.StatusInternalServerError
}
}
return status
}
func mapErrorConstantsToStatus(err error) (int, bool) {
for {
switch err {
case consts.ErrBadRequest:
return http.StatusBadRequest, true
case consts.ErrNotFound:
return http.StatusNotFound, true
case consts.ErrConflict:
return http.StatusConflict, true
default:
werr, ok := err.(interface {
Unwrap() error
})
if !ok {
return 0, false
}
err = werr.Unwrap()
}
}
}
+122
View File
@@ -0,0 +1,122 @@
package server
import (
"context"
"net/http"
"path"
"strings"
"github.com/gin-gonic/gin"
"ruben/inventory2/domains/accounts"
"ruben/inventory2/domains/authentication"
etsy_platform "ruben/inventory2/domains/platforms/etsy"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/logging"
"ruben/inventory2/server/api"
"ruben/inventory2/server/auth"
"ruben/inventory2/server/response"
"ruben/inventory2/server/sse"
"ruben/inventory2/server/ui"
)
type Router struct {
*gin.Engine
sse *sse.Queue
}
func NewRouter(
logger *logging.Logger,
contentDir string,
rawEvents *raw_events.Store,
accts *accounts.Store,
etsy *etsy_platform.Platform,
authr *authentication.Authenticator,
) *Router {
authM := auth.NewAuth(
logger.WithGroup("auth-middleware"),
authr,
accts,
)
r := gin.Default()
r.Use(
authM.Identify,
response.HandleResponses,
response.HandleErrors,
)
// webpage content
// top level GET is assumed to be for the home page.
r.GET("/", func(c *gin.Context) {
c.Redirect(http.StatusMovedPermanently, "/ui")
})
ui.Routes(
logger.WithGroup("ui"),
r.Group("/ui"),
"/ui",
rawEvents,
accts,
etsy,
authM.Authenticate(),
)
// non-html content: scripts, styles, images, etc
r.Use(fileServer("/scripts", contentDir+"/scripts", func(c *gin.Context) {
w := c.Writer
w.Header().Set("Content-Type", "text/javascript")
if path.Ext(c.Request.URL.Path) == ".gz" {
w.Header().Set("Content-Encoding", "gzip")
}
}))
r.Static("/styles", "./styles")
r.Static("/favicon", "./favicon")
r.Static("/images", "./images")
// api endpoints
// sse setup
sq := sse.NewQueue()
unp := sq.NewUpdateNotificationPublisher(
logger.WithGroup("update.notification.publisher"),
func(c *gin.Context) int64 {
return auth.GetIdentity(c).Account.AccountID
},
).Trim("/api")
api.Routes(
r.Group("/api"),
logger.WithGroup("/api"),
authM,
sq,
accts,
unp,
rawEvents,
etsy,
)
return &Router{
Engine: r,
sse: sq,
}
}
func (r *Router) RunSSE(ctx context.Context) error {
return r.sse.Start(ctx)
}
func fileServer(urlPrefix, dir string, beforeServe func(c *gin.Context)) gin.HandlerFunc {
scfs := http.StripPrefix(urlPrefix, http.FileServer(http.Dir(dir)))
return func(c *gin.Context) {
r := c.Request
if r.URL.Path == urlPrefix || strings.HasPrefix(r.URL.Path, path.Join(urlPrefix, "/")) {
if beforeServe != nil {
beforeServe(c)
}
scfs.ServeHTTP(c.Writer, r)
c.Abort()
}
}
}
+160
View File
@@ -0,0 +1,160 @@
package sse
import (
"context"
"errors"
"fmt"
"path"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"ruben/inventory2/logging"
)
type (
UpdateNotificationPublisher struct {
log *logging.Logger
queue sender
getAccountID func(*gin.Context) int64
trimBasePath string
basePathPattern string
}
// sender is satisfied by *Queue
sender interface {
Send(ctx context.Context, e Event) error
}
)
func (q *Queue) NewUpdateNotificationPublisher(
log *logging.Logger,
getAccountID func(*gin.Context) int64,
) *UpdateNotificationPublisher {
return &UpdateNotificationPublisher{
log: log,
queue: q,
getAccountID: getAccountID,
}
}
// Trim produces an *UpdateNotificationPublisher with the pathPattern
// trimmed from events otherwise published by p.
func (p *UpdateNotificationPublisher) Trim(pathPattern string) *UpdateNotificationPublisher {
p2 := *p
p2.trimBasePath = path.Join(p2.trimBasePath, pathPattern)
return &p2
}
// Group produces an *UpdateNotificationPublisher with the pathPattern
// appended to the base path used by p, if any, in publishing events.
// If no base path was set to p, then pathPattern becomes the base path.
func (p *UpdateNotificationPublisher) Group(pathPattern string) *UpdateNotificationPublisher {
p2 := *p
p2.basePathPattern = path.Join(p2.basePathPattern, pathPattern)
return &p2
}
// Publish constructs a gin middleware.
// It must used with and after middleware puts in the Identity into the gin.Context.
func (p *UpdateNotificationPublisher) Publish(pathPattern string) gin.HandlerFunc {
trimBasePathSegs := getPathSegments(p.trimBasePath)
trimmedBasePathPattern := path.Join(p.basePathPattern, pathPattern)
trimmedBasePathPatternSegs := getPathSegments(trimmedBasePathPattern)
fullBasePathPatternSegs := append(trimBasePathSegs, trimmedBasePathPatternSegs...)
return func(c *gin.Context) {
reqPathSegs := getPathSegments(c.Request.URL.Path)
c.Next()
if len(c.Errors) > 0 {
return
}
if len(reqPathSegs) < len(fullBasePathPatternSegs) {
p.log.Errorf("req path is less that the full path: %v, %v", c.Request.URL.Path, path.Join(p.trimBasePath, p.basePathPattern, pathPattern))
return
}
// if the path is a subpath, then emit events along the subpath.
for i, s := range trimmedBasePathPatternSegs {
if isWildcard := s[0] == ':'; isWildcard {
continue
}
rs := reqPathSegs[i]
if isSubpath := s != rs; isSubpath {
continue
}
return
}
topEventParts := reqPathSegs[len(trimBasePathSegs):len(fullBasePathPatternSegs)]
topEvent := strings.Join(topEventParts, "_")
events := make([]string, len(reqPathSegs)-len(fullBasePathPatternSegs)+1)
events[0] = topEvent
parentEvent := topEvent
for i, s := range reqPathSegs[len(fullBasePathPatternSegs):] {
e := parentEvent + "_" + s
events[i+1] = e
parentEvent = e
}
acctID := p.getAccountID(c)
for _, e := range events {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := p.queue.Send(ctx, Event{
AccountID: acctID,
Type: e,
Data: []byte(fmt.Sprintf(`{"eventType": %q}`, e)),
}); err != nil {
p.log.Errorf("failed to send sse event to listener: %v", err)
}
}()
}
}
}
func (p *UpdateNotificationPublisher) Push(ctx context.Context, acctID int64, eventTypes ...string) error {
var wg sync.WaitGroup
wg.Add(len(eventTypes))
errs := make([]error, len(eventTypes))
for i, e := range eventTypes {
i := i
e := e
go func() {
defer wg.Done()
if err := p.queue.Send(ctx, Event{
AccountID: acctID,
Type: e,
Data: []byte(fmt.Sprintf(`{"eventType": %q}`, e)),
}); err != nil {
errs[i] = fmt.Errorf("failed to send sse event to listener: %w", err)
}
}()
}
wg.Wait()
return errors.Join(errs...)
return nil
}
func getPathSegments(p string) []string {
p = path.Clean(p)
if p == "" || p == "." || p == "/" {
return nil
}
return strings.Split(strings.Trim(p, "/"), "/")
}
+135
View File
@@ -0,0 +1,135 @@
package sse
import (
"bytes"
"context"
"fmt"
"maps"
"net/http"
"sync"
)
type (
Queue struct {
in chan Event
out map[int64]map[int]chan Event
ctx context.Context
cancel context.CancelFunc
lock sync.Mutex
}
Event struct {
AccountID int64
Type string
Data []byte
}
)
func NewQueue() *Queue {
ctx, cancel := context.WithCancel(context.Background())
return &Queue{
in: make(chan Event),
out: make(map[int64]map[int]chan Event),
ctx: ctx,
cancel: cancel,
}
}
func (q *Queue) Start(ctx context.Context) error {
defer q.cancel()
for {
select {
case <-ctx.Done():
// we're done piping events
return nil
case e := <-q.in:
if e.AccountID == 0 {
continue
}
// share event with listeners on the account
q.lock.Lock()
for _, out := range q.out[e.AccountID] {
out <- e
}
q.lock.Unlock()
}
}
}
func (q *Queue) Listen(ctx context.Context, acctID int64, fn func(context.Context, *Event) error) error {
// create new out pipe and append it to the queue
out := make(chan Event, 1)
q.lock.Lock()
outs := q.out[acctID]
if outs == nil {
outs = make(map[int]chan Event)
q.out[acctID] = outs
}
var maxID int
for n := range maps.Keys(outs) {
maxID = max(maxID, n)
}
id := maxID + 1
outs[id] = out
q.lock.Unlock()
// delete the pipe when done listening
defer func() {
q.lock.Lock()
delete(q.out[acctID], id)
if len(q.out[acctID]) == 0 {
delete(q.out, acctID)
}
q.lock.Unlock()
}()
for {
select {
case <-q.ctx.Done():
// queue is shut down
return nil
case <-ctx.Done():
// done listening to events
return nil
case e := <-out:
// pass event to the caller
if err := fn(ctx, &e); err != nil {
return err
}
}
}
}
func (q *Queue) Send(ctx context.Context, e Event) error {
select {
case <-q.ctx.Done():
// the queue has closed
return fmt.Errorf("event queue closed: %w", q.ctx.Err())
case <-ctx.Done():
// sender ran out of time
return fmt.Errorf("provided context canceled: %w", ctx.Err())
// push the event onto the queue
case q.in <- e:
}
return nil
}
func (e *Event) Write(w http.ResponseWriter) {
fmt.Fprintf(
w,
"event: %s\ndata: %s\n\n",
e.Type,
bytes.ReplaceAll(e.Data, []byte("\n"), []byte(" ")),
)
w.(http.Flusher).Flush()
}
+327
View File
@@ -0,0 +1,327 @@
package ui
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/angelbeltran/templater"
"github.com/gin-gonic/gin"
"ruben/inventory2/domains/accounts"
etsy_platform "ruben/inventory2/domains/platforms/etsy"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/logging"
"ruben/inventory2/server/auth"
"ruben/inventory2/server/response"
)
type (
webpageRouter struct {
log *logging.Logger
uiPath string
templater *templater.Templater
rawEvents *raw_events.Store
accts *accounts.Store
etsy *etsy_platform.Platform
}
// ErrTemplateNotFound is returned if the reason the template failed to compile
// is due to the template not being found.
ErrTemplateNotFound struct {
err error
}
)
func Routes(
logger *logging.Logger,
r gin.IRoutes,
uiPath string,
rawEvents *raw_events.Store,
accts *accounts.Store,
etsy *etsy_platform.Platform,
authenticate gin.HandlerFunc,
) {
s := &webpageRouter{
log: logger,
uiPath: uiPath,
templater: new(templater.Templater).With(templater.Config{
Funcs: func(name string, props map[string]any) template.FuncMap {
return template.FuncMap{
// 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)
},
// strings
"lowerSnakeCase": func(s string) string {
return strings.ToLower(strings.Join(strings.Split(s, " "), "_"))
},
"splitSnakeCase": func(s string) string {
return strings.Join(strings.Split(s, "_"), " ")
},
"capitalize": func(s string) string {
ss := strings.Split(s, " ")
us := make([]string, len(ss))
for i, v := range ss {
if len(v) == 0 {
us[i] = v
} else {
us[i] = strings.ToUpper(v[:1]) + v[1:]
}
}
return strings.Join(us, " ")
},
"hasPrefix": func(s, prefix string) bool {
return strings.HasPrefix(s, prefix)
},
// 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
},
// html
"rawHTML": func(s string) template.HTML {
return template.HTML(s)
},
"rawHTMLAttr": func(s string) template.HTMLAttr {
return template.HTMLAttr(s)
},
"style": func(kvs ...string) (template.HTMLAttr, error) {
if len(kvs)%2 != 0 {
return "", fmt.Errorf("expected an even number of keys: %d", len(kvs))
}
parts := make([]string, len(kvs)/2)
for i := range parts {
parts[i] = fmt.Sprintf("%s: %s;", kvs[2*i], kvs[2*i+1])
}
return template.HTMLAttr(strings.Join(parts, " ")), nil
},
// json
"prettyPrintJSON": func(j json.RawMessage) string {
b, err := json.MarshalIndent(j, " ", "")
if err != nil {
return string(j)
}
return string(b)
},
"marshalJSON": json.Marshal,
// slices
"newSlice": func(args ...any) []any {
return args
},
"newHTMLSlice": func(args ...string) []template.HTML {
ss := make([]template.HTML, len(args))
for i, s := range args {
ss[i] = template.HTML(s)
}
return ss
},
}
},
}),
rawEvents: rawEvents,
accts: accts,
etsy: etsy,
}
r.GET("", response.Handler(s.redirectToAccountsIfLoggedInWithAnAccount), response.Handler(s.serveTemplate))
r.GET("/*rest", authenticate, response.Handler(s.serveTemplate))
}
func (s *webpageRouter) redirectToAccountsIfLoggedInWithAnAccount(c *gin.Context) (response.Response, error) {
id := auth.GetIdentity(c)
if id.Account == nil || id.Account.AccountID == 0 {
return nil, nil
}
return response.TemporaryRedirect(fmt.Sprintf("%s/accounts/%d", s.uiPath, id.Account.AccountID)), nil
}
// GET /
// compiles the page template or component template matching the url
func (s *webpageRouter) serveTemplate(c *gin.Context) (response.Response, error) {
// trim the url path prefix before loading the templates
c.Request.URL.Path = c.Request.URL.Path[len(s.uiPath):]
defer func() {
c.Request.URL.Path = s.uiPath + c.Request.URL.Path
}()
r := c.Request
ctx := r.Context()
args := []any{
"Request",
r,
// add services and data here
"RawEvents",
s.rawEvents.WithContext(ctx),
"URLCalc",
newURLCalculator(r.URL),
"Accounts",
s.accts.WithContext(ctx),
"Etsy",
s.etsy.WithContext(ctx),
// auth tooling
"Identity",
auth.GetIdentity(ctx),
"Auth",
newTemplateAuthenticator(r),
}
b, err := s.templater.Execute(strings.Trim(r.URL.Path, "/"), args...)
if err != nil {
if isFileNotFoundError(err) || isInvalidWildcardValue(err) {
werr := response.NotFound().
Wrap(ErrTemplateNotFound{
err: err,
}).
Msg("resource not found")
nfb, nferr := s.templater.Execute("not-found", args...)
if nferr != nil {
s.log.Errorf("failed to render not-found page: %v", nferr)
return nil, werr
}
return nil, werr.
HTML(nfb)
}
return nil, err
}
return response.HTML(b), nil
}
func isInvalidWildcardValue(err error) bool {
var te *templater.ErrInvalidWildcardValue
return errors.As(err, &te)
}
func isFileNotFoundError(err error) bool {
var te *templater.ErrNotTemplateFileFound
return errors.As(err, &te)
}
// template tooling
type URLCalculator struct {
url *url.URL
}
func newURLCalculator(u *url.URL) URLCalculator {
cpy := *u
return URLCalculator{
url: &cpy,
}
}
func (c URLCalculator) SetQueryParam(k string, v any) string {
u := *c.url
q := u.Query()
q.Set(k, fmt.Sprint(v))
u.RawQuery = q.Encode()
return u.String()
}
// template authenticator
type templateAuthenticator struct {
req *http.Request
}
func newTemplateAuthenticator(req *http.Request) *templateAuthenticator {
return &templateAuthenticator{
req: req,
}
}
// templateAuthorizationFunc these shouild always return an empty string
type templateAuthorizationFunc = func() (string, error)
func (a *templateAuthenticator) ByMatchingAccountID(acctIDPathPosition int) (string, error) {
return "", authorizeByMatchingAccountID(a.req, acctIDPathPosition)
}
func authorizeByMatchingAccountID(r *http.Request, acctIDPathPosition int) error {
pathParts := strings.Split(strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/"), "/"), "/")
if len(pathParts) < acctIDPathPosition {
return fmt.Errorf("authorization failed due to unexpected path: %s", r.URL.Path)
}
part := pathParts[acctIDPathPosition-1]
acctID, err := strconv.ParseInt(part, 10, 64)
if err != nil {
return response.NotFound().
Msgf("account does not exist: %s", part)
}
id := auth.GetIdentity(r.Context())
if id.Account == nil || id.Account.AccountID != acctID {
return response.Unauthorized().
HTML([]byte(fmt.Sprintf(`
<section class="text-center">
<header class="m-[1em]">
<h2>
Access Not Granted
</h2>
<a href="/ui%s" class="block m-[1em]">
<code>
/ui%s
</code>
</a>
</header>
<div class="m-[2em]">
<p class="italic font-bold">
Sorry, you don't have access to the given page.
</p>
</div>
</section>
<section class="text-center">
<a href="/">
Return to app
</a>
</section>
`, r.URL, r.URL)))
}
return nil
}
func (e ErrTemplateNotFound) Error() string {
if e.err != nil {
return fmt.Sprintf("template not found: %v", e.err)
}
return fmt.Sprintf("template not found")
}
func (e ErrTemplateNotFound) Unwrap() error {
return e.err
}