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
-9
View File
@@ -1,9 +0,0 @@
package consts
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrBadRequest = errors.New("bad request")
)
-423
View File
@@ -1,423 +0,0 @@
package accounts
// to generate StoreWithContext
//go:generate concurry -s Store
import (
"context"
"errors"
"fmt"
"ruben/inventory2/internal/consts"
"ruben/inventory2/internal/logging"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type (
Store struct {
log *logging.Logger
db *pgxpool.Pool
}
Account struct {
AccountIDs
OAuthUser
Email string
}
OAuthUser struct {
UserID string
}
AccountShop struct {
AccountShopIDs
Name string
}
Listing struct {
AccountShopListingIDs
SKU string
Name string
Description string
Count int64
}
)
func NewStore(logger *logging.Logger, db *pgxpool.Pool) *Store {
return &Store{
log: logger,
db: db,
}
}
func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
return NewStoreWithContext(ctx, db)
}
func (db *Store) CreateAccount(ctx context.Context, userID, email string) (Account, error) {
rows, err := db.db.Query(
ctx,
`
INSERT INTO accounts (
user_id,
email
)
VALUES (
@user_id,
@email
)
ON CONFLICT DO NOTHING
RETURNING
account_id
`,
pgx.NamedArgs{
"user_id": userID,
"email": email,
},
)
if err != nil {
return Account{}, fmt.Errorf("failed to perform query: %w", err)
}
acctID, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, fmt.Errorf("account already exists: %w", consts.ErrConflict)
}
return Account{}, fmt.Errorf("failed to scan row: %w", err)
}
return Account{
OAuthUser: OAuthUser{
UserID: userID,
},
AccountIDs: AccountIDs{
AccountID: acctID,
},
Email: email,
}, nil
}
func (db *Store) GetAccount(ctx context.Context, id int64) (Account, error) {
rows, err := db.db.Query(
ctx,
"SELECT email, user_id FROM accounts WHERE account_id = @account_id",
pgx.NamedArgs{
"account_id": id,
},
)
if err != nil {
return Account{}, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Email string
User_id string
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, consts.ErrNotFound
}
return Account{}, fmt.Errorf("failed to scan row: %w", err)
}
return Account{
AccountIDs: AccountIDs{
AccountID: id,
},
OAuthUser: OAuthUser{
r.User_id,
},
Email: r.Email,
}, nil
}
func (db *Store) GetAccountByUserID(ctx context.Context, userID string) (Account, error) {
rows, err := db.db.Query(
ctx,
`SELECT
email, account_id
FROM
accounts
WHERE
user_id = @user_id`,
pgx.NamedArgs{
"user_id": userID,
},
)
if err != nil {
return Account{}, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Email string
Account_ID int64
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, consts.ErrNotFound
}
return Account{}, fmt.Errorf("failed to scan row: %w", err)
}
return Account{
AccountIDs: AccountIDs{
AccountID: r.Account_ID,
},
OAuthUser: OAuthUser{
UserID: userID,
},
Email: r.Email,
}, nil
}
func (db *Store) GetAccountByEmail(ctx context.Context, email string) (Account, error) {
rows, err := db.db.Query(
ctx,
"SELECT account_id, user_id FROM accounts WHERE email = @email",
pgx.NamedArgs{
"email": email,
},
)
if err != nil {
return Account{}, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Account_id int64
User_id string
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Account{}, consts.ErrNotFound
}
return Account{}, fmt.Errorf("failed to scan row: %w", err)
}
return Account{
AccountIDs: AccountIDs{
AccountID: r.Account_id,
},
OAuthUser: OAuthUser{
UserID: r.User_id,
},
Email: email,
}, nil
}
func (db *Store) GetUserAndAccountByAccessToken(ctx context.Context, accessToken string) (OAuthUser, *Account, error) {
rows, err := db.db.Query(
ctx,
`
SELECT
u.user_id,
a.account_id,
a.email
FROM oauth_users u
LEFT JOIN oauth_tokens t
ON u.user_id = id_token_subject
LEFT JOIN accounts a
USING (user_id)
WHERE access_token = @access_token
`,
pgx.NamedArgs{
"access_token": accessToken,
},
)
if err != nil {
return OAuthUser{}, nil, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
User_ID string
Account_ID *int64
Email *string
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return OAuthUser{}, nil, consts.ErrNotFound
}
return OAuthUser{}, nil, fmt.Errorf("failed to scan row: %w", err)
}
user := OAuthUser{UserID: r.User_ID}
var acct *Account
if r.Account_ID != nil {
acct = &Account{
AccountIDs: AccountIDs{
AccountID: *r.Account_ID,
},
OAuthUser: user,
Email: *r.Email,
}
}
return user, acct, nil
}
func (db *Store) GetShops(ctx context.Context, acctID int64) ([]AccountShop, error) {
var shops []AccountShop
for _, v := range getDevShops(acctID) {
shops = append(shops, v)
}
return shops, nil
}
func (db *Store) GetMockShops(ctx context.Context, acctID int64) ([]AccountShop, error) {
infos := getAllMockShopSchemaInfos()
lists := make([][]AccountShop, len(infos))
numShops := 0
for i, in := range infos {
rows, err := db.db.Query(
ctx,
fmt.Sprintf(
`
SELECT
shop_id,
name
FROM
%s
WHERE
account_id = @account_id
`,
in.shopTable,
),
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Shop_id string
Name string
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
list := make([]AccountShop, len(vs))
for i, v := range vs {
list[i] = AccountShop{
AccountShopIDs: NewAccountIDs(acctID).
ShopID(in.platform, v.Shop_id),
Name: v.Name,
}
}
lists[i] = list
numShops += len(list)
}
shops := make([]AccountShop, 0, numShops)
for _, list := range lists {
shops = append(shops, list...)
}
return shops, nil
}
func (db *Store) GetListingsForShop(ctx context.Context, acctID int64, platform Platform, shopID string) ([]Listing, error) {
var vs []Listing
for _, v := range getDevListings(acctID) {
if v.Platform != platform {
continue
}
if v.ShopID != shopID {
continue
}
vs = append(vs, v)
}
return vs, nil
}
func (db *Store) GetMockListingsForShop(ctx context.Context, acctID int64, platform Platform, shopID string) ([]Listing, error) {
in, ok := getMockShopSchemaInfo(platform)
if !ok {
return nil, consts.ErrNotFound
}
rows, err := db.db.Query(
ctx,
fmt.Sprintf(
`
SELECT
listing_id,
sku,
name,
description,
"count"
FROM
%s
WHERE
account_id = @account_id
AND shop_id = @shop_id
`,
in.listingsTable,
),
pgx.NamedArgs{
"account_id": acctID,
"shop_id": shopID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Listing_id string
SKU string
Name string
Description string
Count int64
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
listings := make([]Listing, len(vs))
for i, v := range vs {
listings[i] = Listing{
AccountShopListingIDs: NewAccountIDs(acctID).
ShopID(in.platform, shopID).
ListingID(v.Listing_id),
SKU: v.SKU,
Name: v.Name,
Description: v.Description,
Count: v.Count,
}
}
return listings, nil
}
// additional context
func (db *Store) GetAccountPointerByUserID(ctx context.Context, userID string) (*Account, error) {
acct, err := db.GetAccountByUserID(ctx, userID)
if err == nil {
return &acct, nil
}
if errors.Is(err, consts.ErrNotFound) {
return nil, nil
}
return nil, err
}
-293
View File
@@ -1,293 +0,0 @@
package accounts
func getDevShops(acctID int64) []AccountShop {
return []AccountShop{
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
Name: "Etsy 1",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
Name: "Etsy 2",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "8",
},
Name: "Etsy 3",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Tiktok,
ShopID: "11",
},
Name: "Tiktok 1",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Tiktok,
ShopID: "14",
},
Name: "Tiktok 2",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Tiktok,
ShopID: "17",
},
Name: "Tiktok 3",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Wix,
ShopID: "20",
},
Name: "Wix 1",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Wix,
ShopID: "23",
},
Name: "Wix 2",
},
AccountShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Wix,
ShopID: "26",
},
Name: "Wix 3",
},
}
}
func getDevListings(acctID int64) []Listing {
return []Listing{
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
ListingID: "1",
},
SKU: "sku 1",
Name: "name 1",
Description: "description 1",
Count: 51,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
ListingID: "2",
},
SKU: "sku 2",
Name: "name 2",
Description: "description 2",
Count: 52,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
ListingID: "3",
},
SKU: "sku 3",
Name: "name 3",
Description: "description 3",
Count: 53,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
ListingID: "4",
},
SKU: "sku 4",
Name: "name 4",
Description: "description 4",
Count: 54,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
ListingID: "5",
},
SKU: "sku 5",
Name: "name 5",
Description: "description 5",
Count: 55,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "2",
},
ListingID: "6",
},
SKU: "sku 6",
Name: "name 6",
Description: "description 6",
Count: 56,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
ListingID: "7",
},
SKU: "sku 7",
Name: "name 7",
Description: "description 7",
Count: 57,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
ListingID: "8",
},
SKU: "sku 8",
Name: "name 8",
Description: "description 8",
Count: 58,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
ListingID: "9",
},
SKU: "sku 9",
Name: "name 9",
Description: "description 9",
Count: 59,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
ListingID: "10",
},
SKU: "sku 10",
Name: "name 10",
Description: "description 10",
Count: 60,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
ListingID: "11",
},
SKU: "sku 11",
Name: "name 11",
Description: "description 11",
Count: 61,
},
{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: Etsy,
ShopID: "5",
},
ListingID: "12",
},
SKU: "sku 12",
Name: "name 12",
Description: "description 12",
Count: 62,
},
}
}
-39
View File
@@ -1,39 +0,0 @@
package accounts
type (
AccountIDs struct {
AccountID int64
}
AccountShopIDs struct {
AccountIDs
Platform Platform
ShopID string
}
AccountShopListingIDs struct {
AccountShopIDs
ListingID string
}
)
func NewAccountIDs(acctID int64) AccountIDs {
return AccountIDs{
AccountID: acctID,
}
}
func (ids AccountIDs) ShopID(p Platform, id string) AccountShopIDs {
return AccountShopIDs{
AccountIDs: ids,
Platform: p,
ShopID: id,
}
}
func (ids AccountShopIDs) ListingID(id string) AccountShopListingIDs {
return AccountShopListingIDs{
AccountShopIDs: ids,
ListingID: id,
}
}
-632
View File
@@ -1,632 +0,0 @@
package accounts
import (
"context"
"errors"
"fmt"
"ruben/inventory2/internal/consts"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type (
MockShop struct {
AccountShopIDs
Name string
}
MockListing struct {
AccountShopListingIDs
SKU string
Name string
Description string
Count int64
}
)
func (db *Store) CreateMockShop(ctx context.Context, acctID int64, platform Platform, name string) (shopID uuid.UUID, err error) {
shopTableName, err := getMockShopTableName(platform)
if err != nil {
return uuid.Nil, err
}
tx, err := db.db.Begin(ctx)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to being transaction: %w", err)
}
defer tx.Rollback(ctx)
// TODO: are we really going to just copy the id between accounts and mock.accounts?
// upsert mock account
rows, err := tx.Query(
ctx,
`
WITH existing_account(account_id, user_id) AS (
SELECT
account_id,
user_id
FROM
accounts
WHERE
account_id = @account_id
), existing_mock_account AS (
SELECT
user_id
FROM
accounts
WHERE
account_id = @account_id
), inserted_mock_account AS (
INSERT INTO
mock.accounts (
account_id,
user_id
)
SELECT
account_id,
user_id
FROM
existing_account
ON CONFLICT
DO NOTHING
RETURNING
user_id
)
SELECT
COALESCE(ia.user_id, ea.user_id)
FROM
inserted_mock_account ia
FULL OUTER JOIN
existing_mock_account ea
USING
(user_id)
`,
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to perform query: %w", err)
}
userID, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[string])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return uuid.Nil, fmt.Errorf("%w: account not found", consts.ErrNotFound)
}
return uuid.Nil, fmt.Errorf("failed to scan rows: %w", err)
}
// create the mock shop
shopID = uuid.New()
_, err = tx.Exec(
ctx,
fmt.Sprintf(
`
INSERT INTO
%s (
user_id,
account_id,
shop_id,
name
)
VALUES (
@user_id,
@account_id,
@shop_id,
@name
)
`,
shopTableName,
),
pgx.NamedArgs{
"account_id": acctID,
"user_id": userID,
"shop_id": shopID,
"name": name,
},
)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to perform query to create mock shop: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return uuid.Nil, fmt.Errorf("failed to commit transaction: %w", err)
}
return shopID, nil
}
func (db *Store) ListMockShopsForPlatform(ctx context.Context, acctID int64, platform Platform) ([]MockShop, error) {
shopTableName, err := getMockShopTableName(platform)
if err != nil {
return nil, err
}
rows, err := db.db.Query(
ctx,
fmt.Sprintf(
`
SELECT
shop_id,
name
FROM
%s
WHERE
account_id = @account_id
`,
shopTableName,
),
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Shop_id uuid.UUID
Name string
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
shops := make([]MockShop, len(vs))
for i, v := range vs {
shops[i] = MockShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: platform,
ShopID: v.Shop_id.String(),
},
Name: v.Name,
}
}
return shops, nil
}
func (db *Store) GetMockShop(ctx context.Context, acctID int64, platform Platform, shopID string) (*MockShop, error) {
shopTableName, err := getMockShopTableName(platform)
if err != nil {
return nil, err
}
rows, err := db.db.Query(
ctx,
fmt.Sprintf(
`
SELECT
name
FROM
%s
WHERE
account_id = @account_id
AND shop_id = @shop_id
`,
shopTableName,
),
pgx.NamedArgs{
"account_id": acctID,
"shop_id": shopID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
name, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[string])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, consts.ErrNotFound
}
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
return &MockShop{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: platform,
ShopID: shopID,
},
Name: name,
}, nil
}
func (db *Store) CreateMockListing(ctx context.Context, listing MockListing) (MockListing, error) {
shopTableName, listingTableName, err := getMockShopAndListingsTableNames(listing.Platform)
if err != nil {
return MockListing{}, err
}
listing.ListingID = uuid.New().String()
tag, err := db.db.Exec(
ctx,
fmt.Sprintf(
`
WITH existing_shop(account_id, shop_id) AS (
SELECT
account_id,
shop_id
FROM
%s
WHERE
account_id = @account_id
AND shop_id = @shop_id
), new_values(listing_id, sku, name, description, "count") AS (
SELECT
@listing_id::text,
@sku::text,
@name::text,
@description::text,
@count::integer
)
INSERT INTO
%s (
account_id,
shop_id,
listing_id,
sku,
name,
description,
count
)
SELECT
account_id,
shop_id,
listing_id,
sku,
name,
description,
"count"
FROM
existing_shop
JOIN
new_values
ON
true
`,
shopTableName,
listingTableName,
),
pgx.NamedArgs{
"account_id": listing.AccountID,
"shop_id": listing.ShopID,
"listing_id": listing.ListingID,
"sku": listing.SKU,
"name": listing.Name,
"description": listing.Description,
"count": listing.Count,
},
)
if err != nil {
return MockListing{}, fmt.Errorf("failed to perform query: %w", err)
}
if tag.RowsAffected() == 0 {
return MockListing{}, fmt.Errorf("%w: shop not found", consts.ErrNotFound)
}
return listing, nil
}
func (db *Store) UpdateMockListing(ctx context.Context, listing MockListing) error {
_, listingTableName, err := getMockShopAndListingsTableNames(listing.Platform)
if err != nil {
return err
}
tag, err := db.db.Exec(
ctx,
fmt.Sprintf(
`
UPDATE
%s
SET
sku = @sku,
name = @name,
description = @description,
"count" = @count
WHERE
account_id = @account_id
AND shop_id = @shop_id
AND listing_id = @listing_id
`,
listingTableName,
),
pgx.NamedArgs{
"account_id": listing.AccountID,
"shop_id": listing.ShopID,
"listing_id": listing.ListingID,
"sku": listing.SKU,
"name": listing.Name,
"description": listing.Description,
"count": listing.Count,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: listing not found", consts.ErrNotFound)
}
return nil
}
// TODO: will have to be updated once we start creating mock sync groups
func (db *Store) DeleteMockListing(ctx context.Context, ids AccountShopListingIDs) error {
_, listingTableName, err := getMockShopAndListingsTableNames(ids.Platform)
if err != nil {
return err
}
tag, err := db.db.Exec(
ctx,
fmt.Sprintf(
`
DELETE FROM
%s
WHERE
account_id = @account_id
AND shop_id = @shop_id
AND listing_id = @listing_id
`,
listingTableName,
),
pgx.NamedArgs{
"account_id": ids.AccountID,
"shop_id": ids.ShopID,
"listing_id": ids.ListingID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("%w: listing not found", consts.ErrNotFound)
}
return nil
}
func (db *Store) ListMockListingsForShop(ctx context.Context, acctID int64, platform Platform, shopID string) ([]MockListing, error) {
shopTableName, listingsTableName, err := getMockShopAndListingsTableNames(platform)
if err != nil {
return nil, err
}
rows, err := db.db.Query(
ctx,
fmt.Sprintf(
`
SELECT
true AS shop_exists,
l.listing_id,
l.sku,
l.name,
l.description,
l."count"
FROM
%s AS s
LEFT JOIN
%s AS l
USING
(account_id, shop_id)
WHERE
(account_id IS NULL OR account_id = @account_id)
AND (shop_id IS NULL OR shop_id = @shop_id)
ORDER BY
name
`,
shopTableName,
listingsTableName,
),
pgx.NamedArgs{
"account_id": acctID,
"shop_id": shopID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Shop_exists bool
Listing_id pgtype.Text
SKU pgtype.Text
Name pgtype.Text
Description pgtype.Text
Count pgtype.Int8
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
// check if the shop exists, and confirm there were actually listings (LEFT JOIN)
if len(vs) == 0 {
return nil, fmt.Errorf("%w: shop not found", consts.ErrNotFound)
}
if !vs[0].SKU.Valid {
// shop exists, but there are not listings
return nil, nil
}
listings := make([]MockListing, len(vs))
for i, v := range vs {
listings[i] = MockListing{
AccountShopListingIDs: AccountShopListingIDs{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: platform,
ShopID: shopID,
},
ListingID: v.Listing_id.String,
},
SKU: v.SKU.String,
Name: v.Name.String,
Description: v.Description.String,
Count: v.Count.Int64,
}
}
return listings, nil
}
func getMockShopTableName(platform Platform) (string, error) {
switch platform {
case Amazon:
return "mock.shop_amazon", nil
case BigCartel:
return "mock.shop_big_cartel", nil
case Ebay:
return "mock.shop_ebay", nil
case Ecwid:
return "mock.shop_ecwid", nil
case Etsy:
return "mock.shop_etsy", nil
case Shopify:
return "mock.shop_shopify", nil
case SquareOnline:
return "mock.shop_square_online", nil
case Squarespace:
return "mock.shop_squarespace", nil
case Tiktok:
return "mock.shop_tiktok", nil
case WalmartMarketplace:
return "mock.shop_walmart_marketplace", nil
case Wix:
return "mock.shop_wix", nil
case WooCommerce:
return "mock.shop_woo_commerce", nil
case Zoho:
return "mock.shop_zoho", nil
default:
return "", fmt.Errorf("%w: unrecognized platform", consts.ErrBadRequest)
}
}
type mockShopSchemaInfo struct {
platform Platform
shopTable string
listingsTable string
syncGroupListingDraftsTable string
}
func getMockShopSchemaInfo(platform Platform) (mockShopSchemaInfo, bool) {
for _, in := range getAllMockShopSchemaInfos() {
if in.platform == platform {
return in, true
}
}
return mockShopSchemaInfo{}, false
}
func getAllMockShopSchemaInfos() []mockShopSchemaInfo {
return []mockShopSchemaInfo{
{
platform: Amazon,
shopTable: "mock.shop_amazon",
listingsTable: "mock.shop_amazon_listings",
syncGroupListingDraftsTable: "mock.shop_amazon_sync_group_listing_drafts",
},
{
platform: BigCartel,
shopTable: "mock.shop_big_cartel",
listingsTable: "mock.shop_big_cartel_listings",
syncGroupListingDraftsTable: "mock.shop_big_cartel_sync_group_listing_drafts",
},
{
platform: Ebay,
shopTable: "mock.shop_ebay",
listingsTable: "mock.shop_ebay_listings",
syncGroupListingDraftsTable: "mock.shop_ebay_sync_group_listing_drafts",
},
{
platform: Ecwid,
shopTable: "mock.shop_ecwid",
listingsTable: "mock.shop_ecwid_listings",
syncGroupListingDraftsTable: "mock.shop_ecwid_sync_group_listing_drafts",
},
{
platform: Etsy,
shopTable: "mock.shop_etsy",
listingsTable: "mock.shop_etsy_listings",
syncGroupListingDraftsTable: "mock.shop_etsy_sync_group_listing_drafts",
},
{
platform: Shopify,
shopTable: "mock.shop_shopify",
listingsTable: "mock.shop_shopify_listings",
syncGroupListingDraftsTable: "mock.shop_shopify_sync_group_listing_drafts",
},
{
platform: SquareOnline,
shopTable: "mock.shop_square_online",
listingsTable: "mock.shop_square_online_listings",
syncGroupListingDraftsTable: "mock.shop_square_online_sync_group_listing_drafts",
},
{
platform: Squarespace,
shopTable: "mock.shop_squarespace",
listingsTable: "mock.shop_squarespace_listings",
syncGroupListingDraftsTable: "mock.shop_squarespace_sync_group_listing_drafts",
},
{
platform: Tiktok,
shopTable: "mock.shop_tiktok",
listingsTable: "mock.shop_tiktok_listings",
syncGroupListingDraftsTable: "mock.shop_tiktok_sync_group_listing_drafts",
},
{
platform: WalmartMarketplace,
shopTable: "mock.shop_walmart_marketplace",
listingsTable: "mock.shop_walmart_marketplace_listings",
syncGroupListingDraftsTable: "mock.shop_walmart_marketplace_sync_group_listing_drafts",
},
{
platform: Wix,
shopTable: "mock.shop_wix",
listingsTable: "mock.shop_wix_listings",
syncGroupListingDraftsTable: "mock.shop_wix_sync_group_listing_drafts",
},
{
platform: WooCommerce,
shopTable: "mock.shop_woo_commerce",
listingsTable: "mock.shop_woo_commerce_listings",
syncGroupListingDraftsTable: "mock.shop_woo_commerce_sync_group_listing_drafts",
},
{
platform: Zoho,
shopTable: "mock.shop_zoho",
listingsTable: "mock.shop_zoho_listings",
syncGroupListingDraftsTable: "mock.shop_zoho_sync_group_listing_drafts",
},
}
}
func getMockShopAndListingsTableNames(platform Platform) (shop string, listing string, err error) {
shop, err = getMockShopTableName(platform)
if err != nil {
return "", "", err
}
return shop, shop + "_listings", nil
}
-283
View File
@@ -1,283 +0,0 @@
package accounts
import (
"context"
"errors"
"fmt"
"ruben/inventory2/internal/consts"
"slices"
"strings"
"github.com/jackc/pgx/v5"
)
// TODO: simpify, if possible (single query ideal)
func (db *Store) SetOrderOfPlatformOnAccountPage(
ctx context.Context,
acctID int64,
platform Platform,
orderIndex int,
) (orderedPlatforms []Platform, prevOrderIndex int, err error) {
tx, err := db.db.Begin(ctx)
if err != nil {
return nil, 0, fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback(ctx)
// look up specified indexes
rows, err := tx.Query(
ctx,
`
SELECT
platform,
order_index
FROM
accounts_page_platform_order_indexes
WHERE
account_id = @account_id
ORDER BY
order_index ASC
`,
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, 0, fmt.Errorf("failed to perform query to look up existing indexes: %w", err)
}
indexes, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Platform Platform
Order_index int
}])
if err != nil {
return nil, 0, fmt.Errorf("failed to scan rows for query to look up existing indexes: %w", err)
}
// compute the implied indexes,
// and construct the full sequence of platforms
indexPerPlatform := make(map[Platform]int, len(allPlatforms))
platformPerIndex := make(map[int]Platform, len(allPlatforms))
for _, v := range indexes {
indexPerPlatform[v.Platform] = v.Order_index
platformPerIndex[v.Order_index] = v.Platform
}
prevIndex := -1
for _, p := range allPlatforms {
if _, indexSet := indexPerPlatform[p]; !indexSet {
index := prevIndex
for indexUsed := true; indexUsed; _, indexUsed = platformPerIndex[index] {
index += 1
}
prevIndex = index
indexPerPlatform[p] = index
platformPerIndex[index] = p
}
}
prevOrderIndex = indexPerPlatform[platform]
orderedPlatforms = make([]Platform, len(allPlatforms))
if indexPerPlatform[platform] == orderIndex {
for i := range allPlatforms {
orderedPlatforms[i] = platformPerIndex[i]
}
return orderedPlatforms, prevOrderIndex, nil
}
if increased := orderIndex > prevOrderIndex; increased {
// decrement in indexes between the previous index and new index
for index := prevOrderIndex + 1; index <= orderIndex; index += 1 {
p := platformPerIndex[index]
indexPerPlatform[p] = index - 1
platformPerIndex[index-1] = p
}
indexPerPlatform[platform] = orderIndex
platformPerIndex[orderIndex] = platform
} else {
// increment in indexes between the previous index and new index
for index := prevOrderIndex - 1; index >= orderIndex; index -= 1 {
p := platformPerIndex[index]
indexPerPlatform[p] = index + 1
platformPerIndex[index+1] = p
}
indexPerPlatform[platform] = orderIndex
platformPerIndex[orderIndex] = platform
}
// delete all indexes for the acct in the db,
// then insert all updated indexes
valuesLines := make([]string, len(allPlatforms))
args := pgx.NamedArgs{
"account_id": acctID,
}
for i, p := range allPlatforms {
valuesLines[i] = fmt.Sprintf("(@account_id, @platform_%d, @order_index_%d::smallint)", i, i)
args[fmt.Sprintf("platform_%d", i)] = p
args[fmt.Sprintf("order_index_%d", i)] = indexPerPlatform[p]
orderedPlatforms[i] = platformPerIndex[i]
}
_, err = tx.Exec(
ctx,
fmt.Sprintf(`
WITH deleted_indexes AS (
DELETE FROM
accounts_page_platform_order_indexes
WHERE
account_id = @account_id
RETURNING
account_id
), new_indexes(account_id, platform, order_index) AS (
SELECT DISTINCT
x.account_id, x.platform, x.order_index
FROM
(VALUES %s) AS x(account_id, platform, order_index)
LEFT JOIN
deleted_indexes
ON
x.account_id = deleted_indexes.account_id
)
INSERT INTO
accounts_page_platform_order_indexes (
account_id,
platform,
order_index
)
SELECT
account_id,
platform::Platform,
order_index
FROM
new_indexes
`, strings.Join(valuesLines, ", ")),
args,
)
if err != nil {
return nil, 0, fmt.Errorf("failed to perform query to delete old indexes and insert new indexes: %w", err)
}
if tx.Commit(ctx); err != nil {
return nil, 0, fmt.Errorf("failed to commit txn: %w", err)
}
return orderedPlatforms, prevOrderIndex, nil
}
func (db *Store) GetOrderOfPlatformsOnAccountPage(ctx context.Context, acctID int64) ([]Platform, error) {
// get specified order indexes per platform
rows, err := db.db.Query(
ctx,
`
SELECT
platform,
order_index
FROM
accounts_page_platform_order_indexes
WHERE
account_id = @account_id
`,
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Platform Platform
Order_index int
}
platformsWithIndexes, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Platform Platform
Order_index int
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
// hash the specified platforms by index
platformsByIndex := make(map[int]Platform, len(platformsWithIndexes))
for _, v := range platformsWithIndexes {
platformsByIndex[v.Order_index] = v.Platform
}
// form a sorted list of platforms without indexes specified, sorted alphabetically
allPlatformsByName := make(map[Platform]struct{}, len(allPlatforms))
for _, p := range allPlatforms {
allPlatformsByName[p] = struct{}{}
}
for _, v := range platformsWithIndexes {
delete(allPlatformsByName, v.Platform)
}
platformsWithoutAnIndexSpecified := make([]Platform, 0, len(allPlatformsByName))
for p := range allPlatformsByName {
platformsWithoutAnIndexSpecified = append(platformsWithoutAnIndexSpecified, p)
}
slices.SortFunc(platformsWithoutAnIndexSpecified, func(a, b Platform) int {
al := strings.ToLower(string(a))
bl := strings.ToLower(string(b))
if al < bl {
return -1
}
if bl < al {
return 1
}
return 0
})
// construct the list of the ordered platforms
res := make([]Platform, len(allPlatforms))
nextIndex := 0
for i := range res {
if p, ok := platformsByIndex[i]; ok {
res[i] = p
} else {
res[i] = platformsWithoutAnIndexSpecified[nextIndex]
nextIndex += 1
}
}
return res, nil
}
func (db *Store) GetOrderOfPlatformOnAccountPage(ctx context.Context, acctID int64, platform Platform) (int, error) {
rows, err := db.db.Query(
ctx,
`
SELECT
order_index
FROM
accounts_page_platform_order_indexes
WHERE
account_id = @account_id
AND platform = @platform
`,
pgx.NamedArgs{
"account_id": acctID,
"platform": platform,
},
)
if err != nil {
return 0, fmt.Errorf("failed to perform query: %w", err)
}
i, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, consts.ErrNotFound
}
return 0, fmt.Errorf("failed to scan rows: %w", err)
}
return i, nil
}
-110
View File
@@ -1,110 +0,0 @@
package accounts
import (
"fmt"
"strings"
)
type (
Platform string
)
const (
Amazon Platform = "amazon"
BigCartel Platform = "big_cartel"
Ebay Platform = "ebay"
Ecwid Platform = "ecwid"
Etsy Platform = "Etsy"
Shopify Platform = "shopify"
SquareOnline Platform = "square_online"
Squarespace Platform = "squarespace"
Tiktok Platform = "Tiktok"
WalmartMarketplace Platform = "walmart_marketplace"
Wix Platform = "Wix"
WooCommerce Platform = "woo_commerce"
Zoho Platform = "zoho"
)
var (
allPlatforms = []Platform{
Amazon,
BigCartel,
Ebay,
Ecwid,
Etsy,
Shopify,
SquareOnline,
Squarespace,
Tiktok,
WalmartMarketplace,
Wix,
WooCommerce,
Zoho,
}
)
func NewPlatform(s string) (Platform, error) {
for _, v := range allPlatforms {
if strings.ToLower(s) == strings.ToLower(string(v)) {
return v, nil
}
}
return "", fmt.Errorf("unrecognized constant: %q", s)
}
func (p Platform) PrettyPrint() string {
switch p {
case Etsy:
return "Etsy"
case Tiktok:
return "Tiktok"
case Wix:
return "Wix"
case Ebay:
return "Ebay"
case WalmartMarketplace:
return "Walmart Marketplace"
case Amazon:
return "Amazon"
case BigCartel:
return "Big Cartel"
case Ecwid:
return "Ecwid"
case Zoho:
return "Zoho"
case SquareOnline:
return "Square Online"
case Squarespace:
return "Squarespace"
case WooCommerce:
return "Woo Commerce"
case Shopify:
return "Shopify"
}
return ""
}
// sql.Scanner implementation
func (p *Platform) Scan(src any) error {
var s string
switch v := src.(type) {
case string:
s = v
case byte:
s = string(v)
default:
return fmt.Errorf("unsupported type: %T: %v", src, src)
}
dst, err := NewPlatform(s)
if err == nil {
*p = dst
}
return err
}
// sql/driver.Valuer implementation
func (p Platform) Value() (any, error) {
return string(p), nil
}
@@ -1,153 +0,0 @@
// Code generated by concurry DO NOT EDIT.
// https://github.com/angelbeltran/concurry
// concurry
package accounts
import (
"context"
"github.com/google/uuid"
)
type StoreWithContext struct {
ctx context.Context
*Store
}
func NewStoreWithContext(ctx context.Context, v *Store) *StoreWithContext {
return &StoreWithContext{
ctx: ctx,
Store: v,
}
}
func (v_ctx *StoreWithContext) CreateAccount(userID string, email string) (Account, error) {
return v_ctx.Store.CreateAccount(v_ctx.ctx, userID, email)
}
func (v_ctx *StoreWithContext) GetAccount(id int64) (Account, error) {
return v_ctx.Store.GetAccount(v_ctx.ctx, id)
}
func (v_ctx *StoreWithContext) GetAccountByUserID(userID string) (Account, error) {
return v_ctx.Store.GetAccountByUserID(v_ctx.ctx, userID)
}
func (v_ctx *StoreWithContext) GetAccountByEmail(email string) (Account, error) {
return v_ctx.Store.GetAccountByEmail(v_ctx.ctx, email)
}
func (v_ctx *StoreWithContext) GetUserAndAccountByAccessToken(accessToken string) (OAuthUser, *Account, error) {
return v_ctx.Store.GetUserAndAccountByAccessToken(v_ctx.ctx, accessToken)
}
func (v_ctx *StoreWithContext) GetShops(acctID int64) ([]AccountShop, error) {
return v_ctx.Store.GetShops(v_ctx.ctx, acctID)
}
func (v_ctx *StoreWithContext) GetMockShops(acctID int64) ([]AccountShop, error) {
return v_ctx.Store.GetMockShops(v_ctx.ctx, acctID)
}
func (v_ctx *StoreWithContext) GetListingsForShop(acctID int64, platform Platform, shopID string) ([]Listing, error) {
return v_ctx.Store.GetListingsForShop(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) GetMockListingsForShop(acctID int64, platform Platform, shopID string) ([]Listing, error) {
return v_ctx.Store.GetMockListingsForShop(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) GetAccountPointerByUserID(userID string) (*Account, error) {
return v_ctx.Store.GetAccountPointerByUserID(v_ctx.ctx, userID)
}
func (v_ctx *StoreWithContext) CreateMockShop(acctID int64, platform Platform, name string) (uuid.UUID, error) {
return v_ctx.Store.CreateMockShop(v_ctx.ctx, acctID, platform, name)
}
func (v_ctx *StoreWithContext) ListMockShopsForPlatform(acctID int64, platform Platform) ([]MockShop, error) {
return v_ctx.Store.ListMockShopsForPlatform(v_ctx.ctx, acctID, platform)
}
func (v_ctx *StoreWithContext) GetMockShop(acctID int64, platform Platform, shopID string) (*MockShop, error) {
return v_ctx.Store.GetMockShop(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) CreateMockListing(listing MockListing) (MockListing, error) {
return v_ctx.Store.CreateMockListing(v_ctx.ctx, listing)
}
func (v_ctx *StoreWithContext) UpdateMockListing(listing MockListing) error {
return v_ctx.Store.UpdateMockListing(v_ctx.ctx, listing)
}
func (v_ctx *StoreWithContext) DeleteMockListing(ids AccountShopListingIDs) error {
return v_ctx.Store.DeleteMockListing(v_ctx.ctx, ids)
}
func (v_ctx *StoreWithContext) ListMockListingsForShop(acctID int64, platform Platform, shopID string) ([]MockListing, error) {
return v_ctx.Store.ListMockListingsForShop(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) SetOrderOfPlatformOnAccountPage(acctID int64, platform Platform, orderIndex int) ([]Platform, int, error) {
return v_ctx.Store.SetOrderOfPlatformOnAccountPage(v_ctx.ctx, acctID, platform, orderIndex)
}
func (v_ctx *StoreWithContext) GetOrderOfPlatformsOnAccountPage(acctID int64) ([]Platform, error) {
return v_ctx.Store.GetOrderOfPlatformsOnAccountPage(v_ctx.ctx, acctID)
}
func (v_ctx *StoreWithContext) GetOrderOfPlatformOnAccountPage(acctID int64, platform Platform) (int, error) {
return v_ctx.Store.GetOrderOfPlatformOnAccountPage(v_ctx.ctx, acctID, platform)
}
func (v_ctx *StoreWithContext) CreateSyncGroupListingDraft(acctID int64) (int, error) {
return v_ctx.Store.CreateSyncGroupListingDraft(v_ctx.ctx, acctID)
}
func (v_ctx *StoreWithContext) CreateMockSyncGroupListingDraft(acctID int64) (int, error) {
return v_ctx.Store.CreateMockSyncGroupListingDraft(v_ctx.ctx, acctID)
}
func (v_ctx *StoreWithContext) GetSyncGroupListingDraft(acctID int64, orderIndex int) (SyncGroupListingDraft, error) {
return v_ctx.Store.GetSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex)
}
func (v_ctx *StoreWithContext) GetMockSyncGroupListingDraft(acctID int64, orderIndex int) (SyncGroupListingDraft, error) {
return v_ctx.Store.GetMockSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex)
}
func (v_ctx *StoreWithContext) SetShopInSyncGroupListingDraft(acctID int64, orderIndex int, platform Platform, shopID string) error {
return v_ctx.Store.SetShopInSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex, platform, shopID)
}
func (v_ctx *StoreWithContext) SetShopInMockSyncGroupListingDraft(acctID int64, orderIndex int, platform Platform, shopID string) error {
return v_ctx.Store.SetShopInMockSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex, platform, shopID)
}
func (v_ctx *StoreWithContext) SetListingInSyncGroupListingDraft(acctID int64, orderIndex int, listingID string) error {
return v_ctx.Store.SetListingInSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex, listingID)
}
func (v_ctx *StoreWithContext) SetListingInMockSyncGroupListingDraft(acctID int64, orderIndex int, listingID string) error {
return v_ctx.Store.SetListingInMockSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex, listingID)
}
func (v_ctx *StoreWithContext) DeleteSyncGroupListingDraft(acctID int64, orderIndex int) (int, error) {
return v_ctx.Store.DeleteSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex)
}
func (v_ctx *StoreWithContext) DeleteMockSyncGroupListingDraft(acctID int64, orderIndex int) error {
return v_ctx.Store.DeleteMockSyncGroupListingDraft(v_ctx.ctx, acctID, orderIndex)
}
func (v_ctx *StoreWithContext) GetSyncGroupListingDrafts(acctID int64) ([]SyncGroupListingDraft, error) {
return v_ctx.Store.GetSyncGroupListingDrafts(v_ctx.ctx, acctID)
}
func (v_ctx *StoreWithContext) GetMockSyncGroupListingDrafts(acctID int64) ([]SyncGroupListingDraft, error) {
return v_ctx.Store.GetMockSyncGroupListingDrafts(v_ctx.ctx, acctID)
}
func (v_ctx *StoreWithContext) SaveNewSyncGroup(acctID int64) (SyncGroup, error) {
return v_ctx.Store.SaveNewSyncGroup(v_ctx.ctx, acctID)
}
-819
View File
@@ -1,819 +0,0 @@
package accounts
import (
"context"
"errors"
"fmt"
"ruben/inventory2/internal/consts"
"slices"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
type (
SyncGroup struct {
SyncGroupIDs
Listings []SyncGroupListing
}
SyncGroupListing struct {
SyncGroupIDs
AccountShopIDs
ListingID string
OrderIndex int
}
SyncGroupIDs struct {
AccountIDs
SyncGroupID int64
}
// TODO: the id ACTUALLY is the account_id and order_index
SyncGroupListingDraft struct {
AccountShopIDs
ListingID string
OrderIndex int
}
)
func (db *Store) CreateSyncGroupListingDraft(ctx context.Context, acctID int64) (orderIndex int, err error) {
rows, err := db.db.Query(
ctx,
`
WITH new_order_index AS (
SELECT
COALESCE(MAX(order_index), -1) + 1 AS order_index
FROM
sync_group_listing_drafts
WHERE
account_id = @account_id
)
INSERT INTO
sync_group_listing_drafts (
account_id,
order_index
)
SELECT
@account_id,
order_index
FROM
new_order_index
RETURNING
order_index
`,
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return 0, fmt.Errorf("failed to perform query: %w", err)
}
if orderIndex, err = pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]); err != nil {
return 0, fmt.Errorf("failed to scan rows: %w", err)
}
return orderIndex, nil
}
func (db *Store) CreateMockSyncGroupListingDraft(ctx context.Context, acctID int64) (orderIndex int, err error) {
rows, err := db.db.Query(
ctx,
`
WITH max_order_index(max_index) AS (
SELECT
MAX(order_index) AS max_index
FROM
mock.sync_group_listing_draft_order_indexes
)
INSERT INTO
mock.sync_group_listing_draft_order_indexes (
account_id,
order_index
)
SELECT
@account_id,
COALESCE(max_index, -1) + 1
FROM
max_order_index
RETURNING
order_index
`,
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return 0, fmt.Errorf("failed to perform query: %w", err)
}
if orderIndex, err = pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]); err != nil {
return 0, fmt.Errorf("failed to scan rows: %w", err)
}
return orderIndex, nil
}
func (db *Store) GetSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int) (SyncGroupListingDraft, error) {
rows, err := db.db.Query(
ctx,
`
SELECT
platform,
shop_id,
listing_id
FROM
sync_group_listing_drafts
WHERE
account_id = @account_id
AND order_index = @order_index
`,
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
},
)
if err != nil {
return SyncGroupListingDraft{}, fmt.Errorf("failed to perform query: %w", err)
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[struct {
Platform *Platform
Shop_id *string
Listing_id *string
Order_index *int
}])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return SyncGroupListingDraft{}, consts.ErrNotFound
}
return SyncGroupListingDraft{}, fmt.Errorf("failed to scan rows: %w", err)
}
return SyncGroupListingDraft{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: deref(r.Platform),
ShopID: deref(r.Shop_id),
},
ListingID: deref(r.Listing_id),
OrderIndex: orderIndex,
}, nil
}
func (db *Store) GetMockSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int) (SyncGroupListingDraft, error) {
tx, err := db.db.BeginTx(ctx, pgx.TxOptions{
AccessMode: pgx.ReadOnly,
})
if err != nil {
return SyncGroupListingDraft{}, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
rows, err := tx.Query(
ctx,
`
SELECT
true
FROM
mock.sync_group_listing_draft_order_indexes
WHERE
account_id = @account_id
AND order_index = @order_index
`,
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
},
)
if err != nil {
return SyncGroupListingDraft{}, fmt.Errorf("failed to scan rows: %w", err)
}
if _, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[bool]); err != nil {
return SyncGroupListingDraft{}, fmt.Errorf("failed to scan rows: %w", err)
}
for _, info := range getAllMockShopSchemaInfos() {
rows, err := tx.Query(
ctx,
fmt.Sprintf(
`
SELECT
shop_id,
listing_id
FROM
%s
WHERE
account_id = @account_id
AND order_index = @order_index
`,
info.syncGroupListingDraftsTable,
),
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
},
)
if err != nil {
return SyncGroupListingDraft{}, fmt.Errorf("failed to perform query: %w", err)
}
v, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[struct {
Shop_id pgtype.Text
Listing_id pgtype.Text
}])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
continue
}
return SyncGroupListingDraft{}, fmt.Errorf("faild to scan row: %w", err)
}
return SyncGroupListingDraft{
AccountShopIDs: NewAccountIDs(acctID).
ShopID(info.platform, v.Shop_id.String),
ListingID: v.Listing_id.String,
OrderIndex: orderIndex,
}, nil
}
if err := tx.Commit(ctx); err != nil {
return SyncGroupListingDraft{}, fmt.Errorf("failed to commit transaction: %w", err)
}
return SyncGroupListingDraft{
AccountShopIDs: NewAccountIDs(acctID).
ShopID("", ""),
OrderIndex: orderIndex,
}, nil
}
func (db *Store) SetShopInSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int, platform Platform, shopID string) error {
tag, err := db.db.Exec(
ctx,
`
UPDATE
sync_group_listing_drafts
SET
platform = @platform,
shop_id = @shop_id,
listing_id = NULL
WHERE
account_id = @account_id
AND order_index = @order_index
`,
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
"platform": platform,
"shop_id": shopID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
if tag.RowsAffected() != 1 {
return consts.ErrNotFound
}
return nil
}
func (db *Store) SetShopInMockSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int, platform Platform, shopID string) error {
info, ok := getMockShopSchemaInfo(platform)
if !ok {
return fmt.Errorf("%w: unrecognized platform: %s", consts.ErrNotFound, platform)
}
tx, err := db.db.Begin(ctx)
if err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback(ctx)
_, err = tx.Exec(
ctx,
`
DELETE FROM
mock.sync_group_listing_draft_order_indexes
WHERE
account_id = @account_id
AND order_index = @order_index
RETURNING
account_id,
order_index
`,
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
},
)
if err != nil {
return fmt.Errorf("failed to perform query deleting existing index: %w", err)
}
_, err = tx.Exec(
ctx,
`
INSERT INTO
mock.sync_group_listing_draft_order_indexes (
account_id,
order_index
)
VALUES (
@account_id,
@order_index
)
`,
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
},
)
if err != nil {
// TODO: handle error where account doesn't exist?
return fmt.Errorf("failed to perform query inserting new index: %w", err)
}
_, err = tx.Exec(
ctx,
fmt.Sprintf(
`
INSERT INTO
%s (
account_id,
order_index,
shop_id
)
VALUES (
@account_id,
@order_index,
@shop_id
)
`,
info.syncGroupListingDraftsTable,
),
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
"shop_id": shopID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query inserting specific shop index: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
func (db *Store) SetListingInSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int, listingID string) error {
tag, err := db.db.Exec(
ctx,
`
UPDATE
sync_group_listing_drafts
SET
listing_id = @listing_id
WHERE
account_id = @account_id
AND order_index = @order_index
`,
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
"listing_id": listingID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
if tag.RowsAffected() != 1 {
return consts.ErrNotFound
}
return nil
}
func (db *Store) SetListingInMockSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int, listingID string) error {
for _, info := range getAllMockShopSchemaInfos() {
tag, err := db.db.Exec(
ctx,
fmt.Sprintf(
`
UPDATE
%s
SET
listing_id = @listing_id
WHERE
account_id = @account_id
AND order_index = @order_index
`,
info.syncGroupListingDraftsTable,
),
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
"listing_id": listingID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
if tag.RowsAffected() > 0 {
return nil
}
}
return consts.ErrNotFound
}
func (db *Store) DeleteSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int) (numOfRows int, err error) {
rows, err := db.db.Query(
ctx,
`
WITH deleted_draft AS (
DELETE FROM
sync_group_listing_drafts
WHERE
account_id = @account_id
AND order_index = @order_index
RETURNING
true AS found
)
SELECT
COUNT(*) as num_rows,
COALESCE(dd.found, FALSE) as found
FROM
sync_group_listing_drafts ld
LEFT JOIN
deleted_draft dd
ON TRUE
WHERE
account_id = @account_id
GROUP BY
ld.account_id, dd.found
`,
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
},
)
if err != nil {
return 0, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Found bool
Num_rows int
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, consts.ErrNotFound
}
return 0, fmt.Errorf("failed to scan rows: %w", err)
}
if !r.Found {
return 0, consts.ErrNotFound
}
return r.Num_rows, nil
}
func (db *Store) DeleteMockSyncGroupListingDraft(ctx context.Context, acctID int64, orderIndex int) error {
tag, err := db.db.Exec(
ctx,
`
DELETE FROM
mock.sync_group_listing_draft_order_indexes
WHERE
account_id = @account_id
AND order_index = @order_index
`,
pgx.NamedArgs{
"account_id": acctID,
"order_index": orderIndex,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
if tag.RowsAffected() != 1 {
return consts.ErrNotFound
}
return nil
}
func (db *Store) GetSyncGroupListingDrafts(ctx context.Context, acctID int64) ([]SyncGroupListingDraft, error) {
rows, err := db.db.Query(
ctx,
`
SELECT
order_index,
platform,
shop_id,
listing_id
FROM
sync_group_listing_drafts
WHERE
account_id = @account_id
ORDER BY
order_index ASC
`,
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Order_index int
Platform *Platform
Shop_id *string
Listing_id *string
}
rs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
listings := make([]SyncGroupListingDraft, len(rs))
for i, r := range rs {
listings[i] = SyncGroupListingDraft{
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: deref(r.Platform),
ShopID: deref(r.Shop_id),
},
ListingID: deref(r.Listing_id),
OrderIndex: r.Order_index,
}
}
slices.SortFunc(listings, func(a, b SyncGroupListingDraft) int {
return a.OrderIndex - b.OrderIndex
})
return listings, nil
}
func (db *Store) GetMockSyncGroupListingDrafts(ctx context.Context, acctID int64) ([]SyncGroupListingDraft, error) {
tx, err := db.db.BeginTx(ctx, pgx.TxOptions{
AccessMode: pgx.ReadOnly,
})
if err != nil {
return nil, fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback(ctx)
rows, err := tx.Query(
ctx,
`
SELECT
order_index
FROM
mock.sync_group_listing_draft_order_indexes
WHERE
account_id = @account_id
`,
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
indexes, err := pgx.CollectRows(rows, pgx.RowTo[int])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
listingsByOrderIndex := make(map[int]SyncGroupListingDraft, len(indexes))
for _, n := range indexes {
listingsByOrderIndex[n] = SyncGroupListingDraft{
AccountShopIDs: NewAccountIDs(acctID).
ShopID("", ""),
OrderIndex: n,
}
}
for _, info := range getAllMockShopSchemaInfos() {
rows, err := tx.Query(
ctx,
fmt.Sprintf(
`
SELECT
shop_id,
order_index,
listing_id
FROM
%s
WHERE
account_id = @account_id
`,
info.syncGroupListingDraftsTable,
),
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Shop_id pgtype.Text
Order_index pgtype.Int8
Listing_id pgtype.Text
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
for _, v := range vs {
n := int(v.Order_index.Int64)
l := listingsByOrderIndex[n]
l.Platform = info.platform
l.ShopID = v.Shop_id.String
l.ListingID = v.Listing_id.String
listingsByOrderIndex[n] = l
}
}
listings := make([]SyncGroupListingDraft, 0, len(listingsByOrderIndex))
for _, v := range listingsByOrderIndex {
listings = append(listings, v)
}
slices.SortFunc(listings, func(a, b SyncGroupListingDraft) int {
return a.OrderIndex - b.OrderIndex
})
if err := tx.Commit(ctx); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
return listings, nil
}
func (db *Store) SaveNewSyncGroup(ctx context.Context, acctID int64) (SyncGroup, error) {
txn, err := db.db.Begin(ctx)
if err != nil {
return SyncGroup{}, fmt.Errorf("failed to start transaction: %w", err)
}
defer txn.Rollback(ctx)
rows, err := txn.Query(
ctx,
"SELECT COUNT(*) FROM sync_group_listing_drafts WHERE account_id = @account_id",
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return SyncGroup{}, fmt.Errorf("failed to perform query: %w", err)
}
numDrafts, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int])
if err != nil {
return SyncGroup{}, fmt.Errorf("failed to scan rows: %w", err)
}
if numDrafts < 2 {
return SyncGroup{}, fmt.Errorf("%w: insufficient listings: must be at least 2: %d", consts.ErrConflict, numDrafts)
}
rows, err = txn.Query(
ctx,
`
WITH deleted_sync_group_listing_drafts AS (
DELETE FROM
sync_group_listing_drafts
WHERE
account_id = @account_id
RETURNING
order_index,
platform,
shop_id,
listing_id
), new_sync_group AS (
INSERT INTO
sync_groups (
account_id
)
VALUES (
@account_id
)
RETURNING
sync_group_id
)
INSERT INTO
sync_groups_listings (
sync_group_id,
order_index,
platform,
shop_id,
listing_id
)
SELECT
sync_group_id,
order_index,
platform,
shop_id,
listing_id
FROM
new_sync_group
JOIN
deleted_sync_group_listing_drafts
ON
TRUE
RETURNING
sync_group_id,
order_index,
platform,
shop_id,
listing_id
`,
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return SyncGroup{}, fmt.Errorf("failed to perform query: %w", err)
}
rs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Sync_group_id int64
Order_index int
Platform Platform
Shop_id string
Listing_id string
}])
if err != nil {
return SyncGroup{}, fmt.Errorf("failed to scan rows: %w", err)
}
if err := txn.Commit(ctx); err != nil {
return SyncGroup{}, fmt.Errorf("failed to commit transaction: %w", err)
}
listings := make([]SyncGroupListing, len(rs))
for i, r := range rs {
listings[i] = SyncGroupListing{
SyncGroupIDs: SyncGroupIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
SyncGroupID: r.Sync_group_id,
},
AccountShopIDs: AccountShopIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
Platform: r.Platform,
ShopID: r.Shop_id,
},
ListingID: r.Listing_id,
OrderIndex: r.Order_index,
}
}
slices.SortFunc(listings, func(a, b SyncGroupListing) int {
return a.OrderIndex - b.OrderIndex
})
return SyncGroup{
SyncGroupIDs: SyncGroupIDs{
AccountIDs: AccountIDs{
AccountID: acctID,
},
SyncGroupID: listings[0].SyncGroupID,
},
Listings: listings,
}, nil
}
func deref[T any](ptr *T) T {
if ptr == nil {
var zero T
return zero
}
return *ptr
}
-413
View File
@@ -1,413 +0,0 @@
package authentication
import (
"context"
"errors"
"fmt"
"net/url"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/oauth2"
"ruben/inventory2/internal/consts"
"ruben/inventory2/internal/logging"
)
// TODO: move these to a config?
const (
// The URL of our Auth0 Tenant Domain.
// If you're using a Custom Domain, be sure to set this to that value instead.
AUTH0_DOMAIN = "dev-uq3gqy5bdnwxmr6d.us.auth0.com"
// Our Auth0 application"s Client ID.
AUTH0_CLIENT_ID = "JEjrXTQ9fxlTLgp9RgTIACpUk8a2lqNT"
// Our Auth0 application"s Client Secret.
AUTH0_CLIENT_SECRET = "83U-iWdVaNnwk9XDzteo_2VMyOq_l1siKYqg1_2E7jCzgL8MnkaxlysPMcPMGlxA"
// The Callback URL of our application.
AUTH0_CALLBACK_URL = "https://inventory-plus-plus.com/api/auth/login/callback"
)
type (
// Authenticator is used to authenticate our users.
Authenticator struct {
log *logging.Logger
*oidc.Provider
oauth2.Config
db *pgxpool.Pool
}
// AccessTokenClaims is the claims Auth0 provides in access tokens
AccessTokenClaims struct {
Audience string `json:"aud"` // TODO: fill in from db
Expires int64 `json:"exp"`
Expiration time.Time `json:"-"` // parsed Expires
FamilyName string `json:"family_name"`
GivenName string `json:"given_name"`
IssuedAt int64 `json:"iat"` // TODO: fill in from db
Issuer string `json:"iss"` // TODO: fill in from db
Name string `json:"name"`
Nickname string `json:"nickname"`
Picture string `json:"picture"`
SessionID string `json:"sid"` // TODO: fill in from db
Subject string `json:"sub"` // TODO: fill in from db
UpdatedAt time.Time `json:"updated_at"` // TODO: fill in from db
}
)
// New instantiates the *Authenticator.
func New(
ctx context.Context,
db *pgxpool.Pool,
logger *logging.Logger,
) (*Authenticator, error) {
provider, err := oidc.NewProvider(
ctx,
"https://"+AUTH0_DOMAIN+"/",
)
if err != nil {
return nil, err
}
return &Authenticator{
log: logger,
Provider: provider,
Config: oauth2.Config{
ClientID: AUTH0_CLIENT_ID,
ClientSecret: AUTH0_CLIENT_SECRET,
RedirectURL: AUTH0_CALLBACK_URL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile"},
},
db: db,
}, nil
}
func (a *Authenticator) RunBackgroundCleanup(ctx context.Context) error {
for {
if _, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE expiry < NOW()"); err != nil {
return fmt.Errorf("failed to delete all oauth_tokens rows that are expired: %w", err)
}
if _, err := a.db.Exec(ctx, "DELETE FROM oauth_login_states WHERE expiration < NOW()"); err != nil {
return fmt.Errorf("failed to delete all oauth_login_states rows that are expired: %w", err)
}
select {
case <-ctx.Done():
return nil
case <-time.After(time.Minute):
}
}
}
// Exchange exchanges an auth code for an access token.
func (a *Authenticator) Exchange(ctx context.Context, state, code string) (accessToken, targetURI string, expiration time.Time, err error) {
// validate state
exp, targetURI, err := a.GetStateExpirationAndURL(ctx, state)
if errors.Is(err, consts.ErrNotFound) {
return "", "", time.Time{}, fmt.Errorf("invalid state: %w", consts.ErrNotFound)
} else if err != nil {
return "", "", time.Time{}, fmt.Errorf("failed to load state expiration: %w", err)
} else if exp.Before(time.Now()) {
return "", "", time.Time{}, fmt.Errorf("invalid state: state expired")
}
// obtain token and profile
tkn, err := a.Config.Exchange(ctx, code)
if err != nil {
return "", "", time.Time{}, fmt.Errorf("failed to exchange an authorization code for a token: %w", err)
}
idToken, claims, err := a.verifyIDTokenAndClaimsFromToken(ctx, tkn)
if err != nil {
return "", "", time.Time{}, err
}
// store the token, and the potentially new user
if _, err := a.db.Exec(
ctx,
`
WITH new_user AS (
INSERT INTO oauth_users (
user_id
)
VALUES (
@id_token_subject
)
ON CONFLICT DO NOTHING
RETURNING
user_id
), the_user AS (
SELECT
COALESCE(user_id, user_id_2) as user_id
FROM
new_user
RIGHT JOIN
(SELECT @id_token_subject as user_id_2)
ON TRUE
)
INSERT INTO oauth_tokens (
access_token,
token_type,
refresh_token,
expiry,
id_token_issuer,
id_token_audience,
id_token_subject,
id_token_expiry,
id_token_issued_at,
id_token_nonce,
id_token_access_token_hash,
id_token_custom_claims_family_name,
id_token_custom_claims_given_name,
id_token_custom_claims_name,
id_token_custom_claims_nickname,
id_token_custom_claims_picture,
id_token_custom_claims_updated_at
)
SELECT
@access_token,
@token_type,
@refresh_token,
@expiry,
@id_token_issuer,
@id_token_audience,
user_id,
@id_token_expiry,
@id_token_issued_at,
@id_token_nonce,
@id_token_access_token_hash,
@id_token_custom_claims_family_name,
@id_token_custom_claims_given_name,
@id_token_custom_claims_name,
@id_token_custom_claims_nickname,
@id_token_custom_claims_picture,
@id_token_custom_claims_updated_at
FROM
the_user
`,
pgx.NamedArgs{
"access_token": tkn.AccessToken,
"token_type": tkn.TokenType,
"refresh_token": tkn.RefreshToken,
"expiry": tkn.Expiry,
"id_token_issuer": idToken.Issuer,
"id_token_audience": pgtype.FlatArray[string](idToken.Audience),
"id_token_subject": idToken.Subject,
"id_token_expiry": idToken.Expiry,
"id_token_issued_at": idToken.IssuedAt,
"id_token_nonce": idToken.Nonce,
"id_token_access_token_hash": idToken.AccessTokenHash,
"id_token_custom_claims_family_name": claims.FamilyName,
"id_token_custom_claims_given_name": claims.GivenName,
"id_token_custom_claims_name": claims.Name,
"id_token_custom_claims_nickname": claims.Nickname,
"id_token_custom_claims_picture": claims.Picture,
"id_token_custom_claims_updated_at": claims.UpdatedAt,
},
); err != nil {
return "", "", time.Time{}, fmt.Errorf("failed to perform query to save tokens: %w", err)
}
return tkn.AccessToken, targetURI, tkn.Expiry.UTC(), nil
}
// VerifyIDToken verifies that an *oauth2.Token is a valid *oidc.IDToken.
func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token) (*oidc.IDToken, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil, errors.New("no id_token field in oauth2 token")
}
oidcConfig := &oidc.Config{
ClientID: a.ClientID,
}
return a.Verifier(oidcConfig).Verify(ctx, rawIDToken)
}
func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
return &url.URL{
Scheme: "https",
Host: AUTH0_DOMAIN,
Path: "/v2/logout",
RawQuery: url.Values{
"returnTo": {
(&url.URL{
Scheme: "https",
Host: requestHost,
}).String(),
},
"client_id": {AUTH0_CLIENT_ID},
}.Encode(),
}
}
func (a *Authenticator) RefreshAccessToken(
ctx context.Context,
oldAccessToken string,
) (
accessToken string,
expiration time.Time,
err error,
) {
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, accessToken)
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to load refresh token: %w", err)
}
tkn, err := a.TokenSource(ctx, &oauth2.Token{
// AccessToken is the token that authorizes and authenticates
// the requests.
AccessToken: oldAccessToken,
// TokenType is the type of token.
// The Type method returns either this or "Bearer", the default.
TokenType: tokenType,
// RefreshToken is a token that's used by the application
// (as opposed to the user) to refresh the access token
// if it expires.
RefreshToken: refreshToken,
/*
// Expiry is the optional expiration time of the access token.
//
// If zero, TokenSource implementations will reuse the same
// token forever and RefreshToken or equivalent
// mechanisms for that TokenSource will not be used.
Expiry time.Time `json:"expiry,omitempty"`
*/
}).Token()
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to fetch refresh token: %w", err)
}
idToken, claims, err := a.verifyIDTokenAndClaimsFromToken(ctx, tkn)
if err != nil {
return "", time.Time{}, err
}
if _, err = a.db.Exec(
ctx,
`
WITH deleted_tokens AS (
DELETE FROM
oauth_tokens
WHERE
access_token = @old_access_token
RETURNING
access_token AS old_access_token
), new_tokens AS (
INSERT INTO oauth_tokens (
access_token,
token_type,
refresh_token,
expiry,
id_token_issuer,
id_token_audience,
id_token_subject,
id_token_expiry,
id_token_issued_at,
id_token_nonce,
id_token_access_token_hash,
id_token_custom_claims_family_name,
id_token_custom_claims_given_name,
id_token_custom_claims_name,
id_token_custom_claims_nickname,
id_token_custom_claims_picture,
id_token_custom_claims_updated_at
)
VALUES (
@new_access_token,
@token_type,
@refresh_token,
@expiry,
@id_token_issuer,
@id_token_audience,
@id_token_subject,
@id_token_expiry,
@id_token_issued_at,
@id_token_nonce,
@id_token_access_token_hash,
@id_token_custom_claims_family_name,
@id_token_custom_claims_given_name,
@id_token_custom_claims_name,
@id_token_custom_claims_nickname,
@id_token_custom_claims_picture,
@id_token_custom_claims_updated_at
)
RETURNING
access_token AS new_access_token
)
SELECT
old_access_token,
new_access_token
FROM
deleted_tokens
FULL JOIN
new_tokens
`,
pgx.NamedArgs{
"old_access_token": oldAccessToken,
"new_access_token": tkn.AccessToken,
"token_type": tkn.TokenType,
"refresh_token": tkn.RefreshToken,
"expiry": tkn.Expiry,
"id_token_issuer": idToken.Issuer,
"id_token_audience": pgtype.FlatArray[string](idToken.Audience),
"id_token_subject": idToken.Subject,
"id_token_expiry": idToken.Expiry,
"id_token_issued_at": idToken.IssuedAt,
"id_token_nonce": idToken.Nonce,
"id_token_access_token_hash": idToken.AccessTokenHash,
"id_token_custom_claims_family_name": claims.FamilyName,
"id_token_custom_claims_given_name": claims.GivenName,
"id_token_custom_claims_name": claims.Name,
"id_token_custom_claims_nickname": claims.Nickname,
"id_token_custom_claims_picture": claims.Picture,
"id_token_custom_claims_updated_at": claims.UpdatedAt,
},
); err != nil {
return "", time.Time{}, fmt.Errorf("failed to save new access token and delete old access token: %w", err)
}
return tkn.AccessToken, tkn.Expiry.UTC(), nil
}
func (a *Authenticator) verifyIDTokenAndClaimsFromToken(ctx context.Context, tkn *oauth2.Token) (idToken *oidc.IDToken, claims *AccessTokenClaims, err error) {
if idToken, err = a.VerifyIDToken(ctx, tkn); err != nil {
return nil, nil, fmt.Errorf("failed to verify id Token: %w", err)
}
claims = new(AccessTokenClaims)
if err := idToken.Claims(claims); err != nil {
return nil, nil, fmt.Errorf("failed to obtain id token claims: %w", err)
}
return idToken, claims, nil
}
-189
View File
@@ -1,189 +0,0 @@
package authentication
import (
"context"
"crypto/rand"
"errors"
"fmt"
"ruben/inventory2/internal/consts"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// NewState creates a new state for logging in, saving it in the database.
func (a *Authenticator) NewState(ctx context.Context, targetURI string) ([32]byte, error) {
state, err := generateRandomState()
if err != nil {
return state, fmt.Errorf("failed to generate random state: %w", err)
}
if _, err = a.db.Exec(
ctx,
`
INSERT INTO
oauth_login_states (
state,
target_uri
)
VALUES (
@state,
@target_uri
)
`,
pgx.NamedArgs{
"state": state[:],
"target_uri": targetURI,
},
); err != nil {
return state, fmt.Errorf("failed to execute query: %w", err)
}
return state, nil
}
func generateRandomState() ([32]byte, error) {
var b [32]byte
_, err := rand.Read(b[:])
return b, err
}
// GetStateExpirationAndURL get's the oauth state's expiration
func (a *Authenticator) GetStateExpirationAndURL(ctx context.Context, state string) (time.Time, string, error) {
rows, err := a.db.Query(
ctx,
`
SELECT
expiration,
target_uri
FROM
oauth_login_states
WHERE
state = ('\x' || @state)::BYTEA`,
pgx.NamedArgs{
"state": state,
},
)
if err != nil {
return time.Time{}, "", fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Expiration time.Time
Target_uri pgtype.Text
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return time.Time{}, "", consts.ErrNotFound
}
return time.Time{}, "", fmt.Errorf("failed to scan row: %w", err)
}
return r.Expiration, r.Target_uri.String, nil
}
// TODO: need to automatically clean up expired tokens
func (a *Authenticator) DeleteOAuthTokens(ctx context.Context, accessToken string) error {
_, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE access_token = @access_token", pgx.NamedArgs{"access_token": accessToken})
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
return nil
}
func (a *Authenticator) GetAccessTokenClaimsAndExpiration(ctx context.Context, accessToken string) (claims AccessTokenClaims, err error) {
rows, err := a.db.Query(
ctx,
`
SELECT
expiry,
id_token_custom_claims_name,
id_token_custom_claims_picture,
id_token_custom_claims_nickname,
id_token_custom_claims_given_name,
id_token_custom_claims_family_name,
id_token_custom_claims_updated_at
FROM
oauth_tokens
WHERE
access_token = @access_token
`,
pgx.NamedArgs{
"access_token": accessToken,
},
)
if err != nil {
return AccessTokenClaims{}, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Expiry time.Time
Id_token_custom_claims_name string
Id_token_custom_claims_picture string
Id_token_custom_claims_nickname string
Id_token_custom_claims_given_name string
Id_token_custom_claims_family_name string
Id_token_custom_claims_updated_at time.Time
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return AccessTokenClaims{}, consts.ErrNotFound
}
return AccessTokenClaims{}, fmt.Errorf("failed to scan row: %w", err)
}
claims.Expires = r.Expiry.Unix()
claims.Expiration = r.Expiry
claims.Name = r.Id_token_custom_claims_name
claims.Picture = r.Id_token_custom_claims_picture
claims.Nickname = r.Id_token_custom_claims_nickname
claims.GivenName = r.Id_token_custom_claims_given_name
claims.FamilyName = r.Id_token_custom_claims_family_name
claims.UpdatedAt = r.Id_token_custom_claims_updated_at
return claims, nil
}
func (a *Authenticator) getRefreshTokenForAccessToken(ctx context.Context, accessToken string) (refreshToken, tokenType string, err error) {
rows, err := a.db.Query(
ctx,
`
SELECT
refresh_token,
token_type
FROM
oauth_tokens
WHERE
access_token = @access_token
`,
pgx.NamedArgs{
"access_token": accessToken,
},
)
if err != nil {
return "", "", fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Refresh_token string
Token_type string
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", "", consts.ErrNotFound
}
return "", "", fmt.Errorf("failed to scan row: %w", err)
}
return r.Refresh_token, r.Token_type, nil
}
-47
View File
@@ -1,47 +0,0 @@
package etsy
import (
"context"
"fmt"
"net/http"
"ruben/inventory2/internal/domains/platforms/etsy/generated_client"
)
func newFixedAccessTokenClient(apiKey, accessToken string) (*generated_client.ClientWithResponses, error) {
return newAuthenticatingClient(apiKey, func(ctx context.Context) (string, error) {
return accessToken, nil
})
}
func newAccessTokenRefreshingClient(apiKey string) (*generated_client.ClientWithResponses, error) {
return newAuthenticatingClient(apiKey, func(ctx context.Context) (string, error) {
// TODO: look up the token in the db.
return "", nil
})
}
func newAuthenticatingClient(apiKey string, getAccessToken func(context.Context) (string, error)) (*generated_client.ClientWithResponses, error) {
var setAuthHeaders generated_client.ClientOption = func(c *generated_client.Client) error {
c.RequestEditors = append(c.RequestEditors, generated_client.RequestEditorFn(func(ctx context.Context, req *http.Request) error {
accessToken, err := getAccessToken(req.Context())
if err != nil {
return err
}
h := req.Header
h.Set("x-api-key", apiKey)
h.Set("Authorization", fmt.Sprintf("Bearer %s", accessToken))
return nil
}))
return nil
}
c, err := generated_client.NewClientWithResponses("https://api.etsy.com", setAuthHeaders)
if err != nil {
return nil, fmt.Errorf("failed to initialize open api client: %w", err)
}
return c, nil
}
-308
View File
@@ -1,308 +0,0 @@
package etsy
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"ruben/inventory2/internal/domains/platforms/etsy/generated_client"
"ruben/inventory2/internal/logging"
)
//go:generate oapi-codegen -generate types,client -package generated_client -o generated_client/client.go openapi.3.0.2.json
//go:generate concurry -s Platform
// TODO:
// - [x] document the flow in the README.md
// - [ ] save the initial access/refresh tokens in the db
// - [ ] make calls to refresh the access token and store it in the db.
// - [ ] make cron jobs to automatically refresh the refresh token upon as tokens approach expiration.
// - [ ] make pages to direct to the etsy shop acceptance url/page
// - [ ] stub an account page
type (
Platform struct {
log *logging.Logger
oAuthRedirectURI func(acctID int64) string
apiKeystring string
apiSharedSecret string
db *pgxpool.Pool
}
)
// oauth scopes
const (
scopeAddressRead = "address_r" // Read a member's shipping addresses.
scopeAddressWrite = "address_w" // Update and delete a member's shipping address.
scopeBillingRead = "billing_r" // Read a member's Etsy bill charges and payments.
scopeCartRead = "cart_r" // Read the contents of a members cart.
scopeCartWrite = "cart_w" // Add and remove listings from a member's cart.
scopeEmailRead = "email_r" // Read a user profile
scopeFavoritesRead = "favorites_r" // View a member's favorite listings and users.
scopeFavoritesWrite = "favorites_w" // Add to and remove from a member's favorite listings and users.
scopeFeedbackRead = "feedback_r" // View all details of a member's feedback (including purchase history.)
scopeListings_d = "listings_d" // Delete a member's listings.
scopeListingsRead = "listings_r" // Read a member's inactive and expired (i.e., non-public) listings.
scopeListingsWrite = "listings_w" // Create and edit a member's listings.
scopeProfileRead = "profile_r" // Read a member's private profile information.
scopeProfileWrite = "profile_w" // Update a member's private profile information.
scopeRecommendRead = "recommend_r" // View a member's recommended listings.
scopeRecommendWrite = "recommend_w" // Remove a member's recommended listings.
scopeShopsRead = "shops_r" // See a member's shop description, messages and sections, even if not (yet) public.
scopeShopsWrite = "shops_w" // Update a member's shop description, messages and sections.
scopeTransactionsRead = "transactions_r" // Read a member's purchase and sales data. This applies to buyers as well as sellers.
scopeTransactionsWrite = "transactions_w" // Update a member's sales data.
)
func NewPlatform(logger *logging.Logger, oAuthRedirectURI func(acctID int64) string, apiKeystring, apiSharedSecret string, db *pgxpool.Pool) *Platform {
return &Platform{
log: logger,
oAuthRedirectURI: oAuthRedirectURI,
apiKeystring: apiKeystring,
apiSharedSecret: apiSharedSecret,
db: db,
}
}
func (p *Platform) WithContext(ctx context.Context) *PlatformWithContext {
return NewPlatformWithContext(ctx, p)
}
func (p *Platform) GenerateConnectionURLForNewAccount(ctx context.Context, acctID int64) (*url.URL, error) {
req, err := p.createNewOAuthRequest(ctx, acctID)
if err != nil {
return nil, fmt.Errorf("failed to create oauth request parameters: %w", err)
}
state := req.state
code := req.pkceCode
return &url.URL{
Scheme: "https",
Host: "www.etsy.com",
Path: "/oauth/connect",
RawQuery: url.Values{
"response_type": {"code"},
"redirect_uri": {p.oAuthRedirectURI(acctID)},
"scope": {url.QueryEscape(strings.Join([]string{
scopeCartRead,
scopeCartWrite,
scopeEmailRead,
scopeListingsWrite,
}, ","))},
"client_id": {p.apiKeystring},
"state": {state.String()},
"code_challenge": {fmt.Sprintf("%x", code.challenge)},
"code_challenge_method": {"S256"},
}.Encode(),
}, nil
}
// HandleNewAuthCode handles the auth code to get api access
func (p *Platform) HandleNewAuthCode(ctx context.Context, acctID int64, state, authCode string) (bool, error) {
// look up matching oauth request
stateUUID, err := uuid.Parse(state)
if err != nil {
return false, nil
}
oar, ok, err := p.getOauthRequest(ctx, stateUUID)
if err != nil {
return false, fmt.Errorf("failed to look up existing oauth request: %w", err)
}
if !ok {
return false, nil
}
if oar.acctID != acctID {
return false, p.deleteOauthRequest(ctx, stateUUID)
}
// construct http request to obtain access token
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
(&url.URL{
Scheme: "https://",
Host: "api.etsy.com",
Path: "/v3/public/oauth/token",
}).String(),
bytes.NewReader([]byte(url.Values{
"grant_type": {"authorization_code"},
"client_id": {p.apiKeystring},
"redirect_uri": {p.oAuthRedirectURI(acctID)},
"code": {authCode},
"code_verifier": {fmt.Sprintf("%x", oar.pkceCode.verifier)},
}.Encode())),
)
if err != nil {
return false, fmt.Errorf("failed to generate http request to get oauth tokens: %w", err)
}
req.Header.Set("Content-Type", "x-www-form-urlencoded")
// perform request to obtain access token
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, fmt.Errorf("failed to perform http request: %w", err)
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil && !errors.Is(err, io.EOF) {
return false, fmt.Errorf("failed to read response body: %w", err)
}
if c := resp.StatusCode; (c / 100) != 2 {
return false, fmt.Errorf("unexpected http response code: %d; body = %s", c, string(b))
}
// parse and validate response body
accessToken, refreshToken, expiration, userID, err := p.parseAccessCodeResponseBody(ctx, b)
if err != nil {
return false, fmt.Errorf("failed to parse response body: %w", err)
}
// look up user's shop id
shopID, err := p.getNewUserShopID(ctx, accessToken, userID)
if err != nil {
return false, fmt.Errorf("failed to complete sign on due to failing to look up the user's shop id: %w", err)
}
// save all to database
const ninetyDays = 90 * 24 * time.Hour
if err := p.saveNewEtsyUser(
ctx,
EtsyUser{
AcctID: acctID,
UserID: userID,
ShopID: shopID,
},
etsyAccessTokens{
access: tokenAndExpiration{
token: accessToken,
expiration: expiration,
},
refresh: tokenAndExpiration{
token: refreshToken,
expiration: time.Now().Add(ninetyDays),
},
},
); err != nil {
return false, fmt.Errorf("failed to save new user and access token: %w", err)
}
return true, nil
}
func (p *Platform) parseAccessCodeResponseBody(ctx context.Context, body []byte) (
accessToken string,
refreshToken string,
expiration time.Time,
userID int64, // stored in the access token & refresh token
err error,
) {
// parse and validate response body
var (
tokenType string
expirationInSeconds int
)
if err = json.Unmarshal(body, &struct {
Access_Token *string
Token_Type *string
Expires_In *int
Refresh_Token *string
}{
Access_Token: &accessToken,
Token_Type: &tokenType,
Expires_In: &expirationInSeconds,
Refresh_Token: &refreshToken,
}); err != nil {
err = fmt.Errorf("failed to decode request body as json: %w", err)
return
}
if v := accessToken; v == "" {
err = fmt.Errorf("no access token specified in access token response: body = %s", string(body))
return
} else if accessTokenParts := strings.SplitN(accessToken, ".", 2); len(accessTokenParts) < 2 {
err = fmt.Errorf("unexpected access token format: expected a user_id prefix: <user_id>.<remaindeder>: %s: body = %s", accessToken, string(body))
return
} else if i, ierr := strconv.ParseInt(accessTokenParts[0], 10, 64); ierr != nil {
err = fmt.Errorf("unexpected user_id in access token: should be an integer: %s: %w", accessTokenParts[0], ierr)
return
} else if i <= 0 {
err = fmt.Errorf("unexpected user_id in access token: should be a positive integer: %d: %w", i, err)
} else {
userID = i
}
if tt := tokenType; tt == "" {
err = fmt.Errorf("no token type specified in access token response: body = %s", string(body))
return
} else if tt != "Bearer" {
err = fmt.Errorf("unexpected token type specified in access token response: %s: body = %s", tt, string(body))
return
}
if v := expirationInSeconds; v == 0 {
err = fmt.Errorf("no expiration specified in access token response: body = %s", string(body))
return
} else if v < 0 {
err = fmt.Errorf("unexpected expiration specified in access token response: %d: body = %s", v, string(body))
return
} else {
expiration = time.Now().UTC().Add(time.Duration(expirationInSeconds) * time.Second)
}
if v := refreshToken; v == "" {
err = fmt.Errorf("no refresh token specified in access token response: body = %s", string(body))
return
}
return
}
func (p *Platform) getNewUserShopID(ctx context.Context, accessToken string, userID int64) (int64, error) {
cli, err := newFixedAccessTokenClient(p.apiKeystring, accessToken)
if err != nil {
return 0, fmt.Errorf("failed to initialize openapi client: %w", err)
}
var res *generated_client.GetShopByOwnerUserIdResponse
if res, err = cli.GetShopByOwnerUserIdWithResponse(ctx, userID); err != nil {
return 0, fmt.Errorf("failed to obtain shop id due to failure to initialize request to obtain shop id: %w", err)
} else if res.JSON400 != nil {
return 0, fmt.Errorf("failed to look up shop for user %d due to 400 error: %s", userID, res.JSON400.Error)
} else if res.JSON403 != nil {
return 0, fmt.Errorf("failed to look up shop for user %d due to 403 error: %s", userID, res.JSON403.Error)
} else if res.JSON404 != nil {
return 0, fmt.Errorf("failed to look up shop for user %d due to 404 error: %s", userID, res.JSON404.Error)
} else if res.JSON500 != nil {
return 0, fmt.Errorf("failed to look up shop for user %d due to 500 error: %s", userID, res.JSON500.Error)
}
if res.JSON200.ShopId == nil {
return 0, fmt.Errorf("shop for user %d has no shop_id set: body = %s", userID, res.Body)
}
return *res.JSON200.ShopId, nil
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,63 +0,0 @@
// Code generated by concurry DO NOT EDIT.
// https://github.com/angelbeltran/concurry
// concurry
package etsy
import (
"context"
"github.com/google/uuid"
"net/url"
"time"
)
type PlatformWithContext struct {
ctx context.Context
*Platform
}
func NewPlatformWithContext(ctx context.Context, v *Platform) *PlatformWithContext {
return &PlatformWithContext{
ctx: ctx,
Platform: v,
}
}
func (v_ctx *PlatformWithContext) GenerateConnectionURLForNewAccount(acctID int64) (*url.URL, error) {
return v_ctx.Platform.GenerateConnectionURLForNewAccount(v_ctx.ctx, acctID)
}
func (v_ctx *PlatformWithContext) HandleNewAuthCode(acctID int64, state string, authCode string) (bool, error) {
return v_ctx.Platform.HandleNewAuthCode(v_ctx.ctx, acctID, state, authCode)
}
func (v_ctx *PlatformWithContext) parseAccessCodeResponseBody(body []byte) (string, string, time.Time, int64, error) {
return v_ctx.Platform.parseAccessCodeResponseBody(v_ctx.ctx, body)
}
func (v_ctx *PlatformWithContext) getNewUserShopID(accessToken string, userID int64) (int64, error) {
return v_ctx.Platform.getNewUserShopID(v_ctx.ctx, accessToken, userID)
}
func (v_ctx *PlatformWithContext) GetUserPointerByAccountID(acctID int64) (*EtsyUser, error) {
return v_ctx.Platform.GetUserPointerByAccountID(v_ctx.ctx, acctID)
}
func (v_ctx *PlatformWithContext) saveNewEtsyUser(user EtsyUser, tokens etsyAccessTokens) error {
return v_ctx.Platform.saveNewEtsyUser(v_ctx.ctx, user, tokens)
}
func (v_ctx *PlatformWithContext) createNewOAuthRequest(acctID int64) (oauth2Request, error) {
return v_ctx.Platform.createNewOAuthRequest(v_ctx.ctx, acctID)
}
func (v_ctx *PlatformWithContext) getOauthRequest(state uuid.UUID) (oauth2Request, bool, error) {
return v_ctx.Platform.getOauthRequest(v_ctx.ctx, state)
}
func (v_ctx *PlatformWithContext) InvalidateState(state string) error {
return v_ctx.Platform.InvalidateState(v_ctx.ctx, state)
}
func (v_ctx *PlatformWithContext) deleteOauthRequest(state uuid.UUID) error {
return v_ctx.Platform.deleteOauthRequest(v_ctx.ctx, state)
}
-269
View File
@@ -1,269 +0,0 @@
package etsy
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
type (
EtsyUser struct {
AcctID int64
UserID int64
ShopID int64
}
etsyAccessTokens struct {
access tokenAndExpiration
refresh tokenAndExpiration
}
tokenAndExpiration struct {
token string
expiration time.Time
}
oauth2Request struct {
acctID int64
state uuid.UUID
expiration time.Time
pkceCode pkceCode
}
pkceCode struct {
verifier [32]byte
challenge []byte
}
)
func (p *Platform) GetUserPointerByAccountID(ctx context.Context, acctID int64) (*EtsyUser, error) {
rows, err := p.db.Query(
ctx,
"SELECT user_id, shop_id FROM etsy_users WHERE account_id = @account_id",
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
User_ID int64
Shop_ID int64
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, fmt.Errorf("failed to scan row: %w", err)
}
return &EtsyUser{
AcctID: acctID,
UserID: r.User_ID,
ShopID: r.Shop_ID,
}, nil
}
func (p *Platform) saveNewEtsyUser(
ctx context.Context,
user EtsyUser,
tokens etsyAccessTokens,
) (err error) {
_, err = p.db.Exec(
ctx,
`
WITH new_user (
INSERT INTO etsy_users (
account_id,
user_id,
shop_id
)
VALUES (
@account_id,
@user_id,
@shop_id
)
RETURNING
account_id,
user_id,
shop_id
)
INSERT INTO etsy_access_tokens (
access_token,
refresh_token,
access_token_expiration,
refresh_token_expiration
)
VALUE (
@access_token,
@refresh_token,
@access_token_expiration,
@refresh_token_expiration
)
`,
pgx.NamedArgs{
"account_id": user.AcctID,
"user_id": user.UserID,
"shop_id": user.ShopID,
"access_token": tokens.access.token,
"refresh_token": tokens.refresh.token,
"access_token_expiration": tokens.access.expiration,
"refresh_token_expiration": tokens.refresh.expiration,
},
)
if err != nil {
return fmt.Errorf("failed to insert new records: %w", err)
}
return nil
}
// TODO: clean these up on timer.
func (p *Platform) createNewOAuthRequest(ctx context.Context, acctID int64) (oauth2Request, error) {
req := oauth2Request{
acctID: acctID,
state: uuid.New(),
expiration: time.Now().UTC().Add(10 * time.Minute),
pkceCode: newPKCECode(),
}
_, err := p.db.Exec(
ctx,
`
INSERT INTO etsy_oauth_requests (
account_id,
state,
code_verifier,
expiration
)
VALUES (
@account_id,
@state,
@code_verifier,
@expiration
)
`,
pgx.NamedArgs{
"account_id": req.acctID,
"state": req.state[:],
"code_verifier": req.pkceCode.verifier[:],
"expiration": req.expiration,
},
)
if err != nil {
return oauth2Request{}, fmt.Errorf("failed to insert record: %w", err)
}
return req, nil
}
func (p *Platform) getOauthRequest(ctx context.Context, state uuid.UUID) (req oauth2Request, ok bool, err error) {
stateBytes := [16]byte(state)
rows, err := p.db.Query(
ctx,
`
SELECT
account_id,
code_verifier,
expiration
FROM
etsy_oauth_requests
WHERE
state = @state
`,
pgx.NamedArgs{
"state": stateBytes[:],
},
)
if err != nil {
return oauth2Request{}, false, fmt.Errorf("failed to perform query: %w", err)
}
type Row struct {
Account_ID int64
Code_Verifier []byte
Expiration time.Time
}
r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return oauth2Request{}, false, nil
}
}
if l := len(r.Code_Verifier); l != 32 {
return oauth2Request{}, false, fmt.Errorf("code_verifier of unexpected length found: expected = 32, found = %d", l)
}
var verifier [32]byte
copy(verifier[:], r.Code_Verifier)
return oauth2Request{
acctID: r.Account_ID,
state: state,
expiration: r.Expiration.UTC(),
pkceCode: pkceCode{
verifier: verifier,
challenge: generateCodeChallenge(verifier),
},
}, false, nil
}
func (p *Platform) InvalidateState(ctx context.Context, state string) error {
stateUUID, err := uuid.Parse(state)
if err != nil {
return nil
}
return p.deleteOauthRequest(ctx, stateUUID)
}
func (p *Platform) deleteOauthRequest(ctx context.Context, state uuid.UUID) error {
_, err := p.db.Exec(
ctx,
`
DELETE FROM etsy_oauth_requests
WHERE state = @state
`,
pgx.NamedArgs{
"state": state,
},
)
if err != nil {
return fmt.Errorf("failed to execute query: %w", err)
}
return nil
}
func newPKCECode() pkceCode {
var code pkceCode
part1 := [16]byte(uuid.New())
part2 := [16]byte(uuid.New())
copy(code.verifier[0:16], part1[:])
copy(code.verifier[16:32], part2[:])
code.challenge = generateCodeChallenge(code.verifier)
return code
}
func generateCodeChallenge(codeVerifier [32]byte) []byte {
return generateSHA256Hash(codeVerifier[:])
}
func generateSHA256Hash(b []byte) []byte {
h := sha256.New()
h.Write(b)
return h.Sum(nil)
}
-117
View File
@@ -1,117 +0,0 @@
package raw_events
import (
"context"
"encoding/json"
"fmt"
"ruben/inventory2/internal/logging"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
)
type (
Store struct {
log *logging.Logger
db *pgxpool.Pool
}
Event struct {
Platform string
StoreID string
EventID string
EventTimestamp time.Time
Payload json.RawMessage
}
)
func NewStore(logger *logging.Logger, db *pgxpool.Pool) *Store {
return &Store{
log: logger,
db: db,
}
}
func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
return NewStoreWithContext(ctx, db)
}
func (db *Store) Save(ctx context.Context, e *Event) error {
_, err := db.db.Exec(
ctx,
`
INSERT INTO raw_store_events (
platform,
store_id,
event_timestamp,
event_id,
raw_payload
)
VALUES (
@platform,
@store_id,
@event_timestamp,
@event_id,
@raw_payload
)
`,
pgx.NamedArgs{
"platform": e.Platform,
"store_id": e.StoreID,
"event_timestamp": pgtype.Timestamptz{
Time: e.EventTimestamp,
Valid: !e.EventTimestamp.IsZero(),
},
"event_id": e.EventID,
"raw_payload": string(e.Payload),
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
return nil
}
func (db *Store) LoadEventsForStore(
ctx context.Context,
platform string,
storeID string,
) ([]Event, error) {
rows, err := db.db.Query(
ctx,
`
SELECT
platform as Platform,
store_id as StoreID,
event_timestamp as EventTimestamp,
event_id as EventID,
raw_payload as Payload
FROM
raw_store_events
WHERE
platform = @platform
AND store_id = @store_id
ORDER BY
event_timestamp DESC
LIMIT
100
`,
pgx.NamedArgs{
"platform": platform,
"store_id": storeID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
evts, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[Event])
if err != nil {
return nil, fmt.Errorf("failed to scan events: %w", err)
}
return evts, nil
}
@@ -1,28 +0,0 @@
// Code generated by concurry DO NOT EDIT.
// https://github.com/angelbeltran/concurry
// concurry
package raw_events
import (
"context"
)
type StoreWithContext struct {
ctx context.Context
*Store
}
func NewStoreWithContext(ctx context.Context, v *Store) *StoreWithContext {
return &StoreWithContext{
ctx: ctx,
Store: v,
}
}
func (v_ctx *StoreWithContext) Save(e *Event) error {
return v_ctx.Store.Save(v_ctx.ctx, e)
}
func (v_ctx *StoreWithContext) LoadEventsForStore(platform string, storeID string) ([]Event, error) {
return v_ctx.Store.LoadEventsForStore(v_ctx.ctx, platform, storeID)
}
-180
View File
@@ -1,180 +0,0 @@
package logging
import (
"context"
"fmt"
"log/slog"
"runtime"
"time"
)
// Logger creates a *slog.Logger wrap with a few more methods wrapped on top.
// It recreates a number of methods to allow replacing a *slog.Logger functionally.
// It also implements a number of methods to support formatted messages and
// tracing.
type Logger struct {
*slog.Logger
}
func New(h slog.Handler) *Logger {
return &Logger{
Logger: slog.New(h),
}
}
func From(l *slog.Logger) *Logger {
return &Logger{
Logger: l,
}
}
func (l *Logger) With(args ...any) *Logger {
return From(l.Logger.With(args...))
}
func (l *Logger) WithGroup(name string) *Logger {
return From(l.Logger.WithGroup(name))
}
func (l *Logger) Debugf(format string, args ...any) {
l.logContextf(context.Background(), slog.LevelDebug, format, args...)
}
func (l *Logger) DebugContextf(ctx context.Context, format string, args ...any) {
l.logContextf(ctx, slog.LevelDebug, format, args...)
}
func (l *Logger) DebugDeferf(format string, args ...any) func(func() (string, []any)) {
return l.logContextDeferf(context.Background(), slog.LevelDebug, format, args...)
}
func (l *Logger) DebugContextDeferf(ctx context.Context, format string, args ...any) func(func() (string, []any)) {
return l.logContextDeferf(ctx, slog.LevelDebug, format, args...)
}
func (l *Logger) DebugCallf(method string) func() {
return l.logContextCallf(context.Background(), slog.LevelDebug, method)
}
func (l *Logger) DebugContextCallf(ctx context.Context, method string) func() {
return l.logContextCallf(ctx, slog.LevelDebug, method)
}
func (l *Logger) Errorf(format string, args ...any) {
l.logContextf(context.Background(), slog.LevelError, format, args...)
}
func (l *Logger) ErrorContextf(ctx context.Context, format string, args ...any) {
l.logContextf(ctx, slog.LevelError, format, args...)
}
func (l *Logger) ErrorDeferf(format string, args ...any) func(func() (string, []any)) {
return l.logContextDeferf(context.Background(), slog.LevelError, format, args...)
}
func (l *Logger) ErrorContextDeferf(ctx context.Context, format string, args ...any) func(func() (string, []any)) {
return l.logContextDeferf(ctx, slog.LevelError, format, args...)
}
func (l *Logger) ErrorCallf(method string) func() {
return l.logContextCallf(context.Background(), slog.LevelError, method)
}
func (l *Logger) ErrorContextCallf(ctx context.Context, method string) func() {
return l.logContextCallf(ctx, slog.LevelError, method)
}
func (l *Logger) Infof(format string, args ...any) {
l.logContextf(context.Background(), slog.LevelInfo, format, args...)
}
func (l *Logger) InfoContextf(ctx context.Context, format string, args ...any) {
l.logContextf(ctx, slog.LevelInfo, format, args...)
}
func (l *Logger) InfoDeferf(format string, args ...any) func(func() (string, []any)) {
return l.logContextDeferf(context.Background(), slog.LevelInfo, format, args...)
}
func (l *Logger) InfoContextDeferf(ctx context.Context, format string, args ...any) func(func() (string, []any)) {
return l.logContextDeferf(ctx, slog.LevelInfo, format, args...)
}
func (l *Logger) InfoCallf(method string) func() {
return l.logContextCallf(context.Background(), slog.LevelInfo, method)
}
func (l *Logger) InfoContextCallf(ctx context.Context, method string) func() {
return l.logContextCallf(ctx, slog.LevelInfo, method)
}
func (l *Logger) Warnf(format string, args ...any) {
l.logContextf(context.Background(), slog.LevelWarn, format, args...)
}
func (l *Logger) WarnContextf(ctx context.Context, format string, args ...any) {
l.logContextf(ctx, slog.LevelWarn, format, args...)
}
func (l *Logger) WarnDeferf(format string, args ...any) func(func() (string, []any)) {
return l.logContextDeferf(context.Background(), slog.LevelWarn, format, args...)
}
func (l *Logger) WarnContextDeferf(ctx context.Context, format string, args ...any) func(func() (string, []any)) {
return l.logContextDeferf(ctx, slog.LevelWarn, format, args...)
}
func (l *Logger) WarnCallf(method string) func() {
return l.logContextCallf(context.Background(), slog.LevelWarn, method)
}
func (l *Logger) WarnContextCallf(ctx context.Context, method string) func() {
return l.logContextCallf(ctx, slog.LevelWarn, method)
}
func (l *Logger) logContextf(ctx context.Context, lvl slog.Level, format string, args ...any) {
if !l.Enabled(ctx, slog.LevelInfo) {
return
}
var pcs [1]uintptr
runtime.Callers(3, pcs[:]) // skip [Callers, Infof]
_ = l.Handler().Handle(
ctx,
slog.NewRecord(time.Now(), lvl, fmt.Sprintf(format, args...), pcs[0]),
)
}
func (l *Logger) logContextCallf(ctx context.Context, lvl slog.Level, method string) func() {
fn := l.With("method", method).logContextDeferf(ctx, lvl, "%s called", method)
return func() {
fn(func() (string, []any) {
return "%s returned", []any{method}
})
}
}
func (l *Logger) logContextDeferf(ctx context.Context, lvl slog.Level, format string, args ...any) func(func() (msg string, args []any)) {
if !l.Enabled(ctx, slog.LevelInfo) {
return func(func() (string, []any)) {
}
}
var pcs [1]uintptr
runtime.Callers(3, pcs[:]) // skip [Callers, Infof]
pc := pcs[0]
_ = l.Handler().Handle(ctx, slog.NewRecord(time.Now(), lvl, fmt.Sprintf(format, args...), pc))
return func(deferred func() (newFormat string, moreArgs []any)) {
if !l.Enabled(ctx, slog.LevelInfo) {
return
}
if deferred != nil {
format, args = deferred()
}
_ = l.Handler().Handle(ctx, slog.NewRecord(time.Now(), lvl, fmt.Sprintf(format, args...), pc))
}
}
-491
View File
@@ -1,491 +0,0 @@
package accounts
import (
"errors"
"fmt"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"ruben/inventory2/internal/consts"
"ruben/inventory2/internal/domains/accounts"
"ruben/inventory2/internal/logging"
"ruben/inventory2/internal/server/auth"
"ruben/inventory2/internal/server/param"
"ruben/inventory2/internal/server/response"
"ruben/inventory2/internal/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
@@ -1,56 +0,0 @@
package api
import (
"github.com/gin-gonic/gin"
"ruben/inventory2/internal/domains/accounts"
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events"
"ruben/inventory2/internal/logging"
accounts_api "ruben/inventory2/internal/server/api/accounts"
auth_api "ruben/inventory2/internal/server/api/auth"
sse_api "ruben/inventory2/internal/server/api/sse"
"ruben/inventory2/internal/server/api/webhooks"
etsy_webhooks "ruben/inventory2/internal/server/api/webhooks/etsy"
"ruben/inventory2/internal/server/auth"
"ruben/inventory2/internal/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",
},
},
)
}
-93
View File
@@ -1,93 +0,0 @@
package auth
import (
"context"
"fmt"
"ruben/inventory2/internal/domains/authentication"
"ruben/inventory2/internal/logging"
"ruben/inventory2/internal/server/cookies"
"ruben/inventory2/internal/server/response"
"github.com/gin-gonic/gin"
)
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
@@ -1,118 +0,0 @@
package sse
import (
"context"
"sync"
"github.com/gin-gonic/gin"
"ruben/inventory2/internal/logging"
"ruben/inventory2/internal/server/auth"
"ruben/inventory2/internal/server/response"
"ruben/inventory2/internal/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
}
@@ -1,152 +0,0 @@
package etsy
import (
"encoding/json"
"fmt"
"strconv"
"time"
"ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events"
"ruben/inventory2/internal/logging"
"ruben/inventory2/internal/server/response"
"github.com/gin-gonic/gin"
)
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
}
@@ -1,43 +0,0 @@
package tiktok
import (
"encoding/json"
"fmt"
"time"
"ruben/inventory2/internal/domains/raw_events"
"ruben/inventory2/internal/logging"
"ruben/inventory2/internal/server/response"
"github.com/gin-gonic/gin"
)
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
@@ -1,42 +0,0 @@
package webhooks
import (
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events"
"ruben/inventory2/internal/logging"
"ruben/inventory2/internal/server/api/webhooks/etsy"
"ruben/inventory2/internal/server/api/webhooks/tiktok"
"ruben/inventory2/internal/server/api/webhooks/wix"
"github.com/gin-gonic/gin"
)
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,
)
}
@@ -1,43 +0,0 @@
package wix
import (
"encoding/json"
"fmt"
"time"
"ruben/inventory2/internal/domains/raw_events"
"ruben/inventory2/internal/logging"
"ruben/inventory2/internal/server/response"
"github.com/gin-gonic/gin"
)
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
@@ -1,191 +0,0 @@
package auth
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"time"
"ruben/inventory2/internal/consts"
"ruben/inventory2/internal/domains/accounts"
"ruben/inventory2/internal/domains/authentication"
"ruben/inventory2/internal/logging"
"ruben/inventory2/internal/server/cookies"
"ruben/inventory2/internal/server/response"
"github.com/gin-gonic/gin"
)
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
@@ -1,17 +0,0 @@
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
@@ -1,11 +0,0 @@
package cookies
import "net/http"
func Expired(name string) http.Cookie {
return http.Cookie{
Name: name,
Path: "/",
MaxAge: -1, // expire the cookie
}
}
-50
View File
@@ -1,50 +0,0 @@
package param
import (
"encoding"
"ruben/inventory2/internal/domains/accounts"
"strconv"
)
// 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
@@ -1,76 +0,0 @@
package param
import (
"encoding"
"github.com/gin-gonic/gin"
"ruben/inventory2/internal/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
@@ -1,19 +0,0 @@
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)
}
-151
View File
@@ -1,151 +0,0 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/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
}
-154
View File
@@ -1,154 +0,0 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/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
}
-185
View File
@@ -1,185 +0,0 @@
package response
import (
"errors"
"fmt"
"net/http"
"ruben/inventory2/internal/consts"
"strings"
)
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
@@ -1,26 +0,0 @@
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()
}
}
}
-212
View File
@@ -1,212 +0,0 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/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
}
-148
View File
@@ -1,148 +0,0 @@
package response
import (
"bytes"
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/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
}
-150
View File
@@ -1,150 +0,0 @@
package response
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/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
}
-191
View File
@@ -1,191 +0,0 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/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
@@ -1,40 +0,0 @@
package response
import (
"io"
"net/http"
"ruben/inventory2/internal/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
@@ -1,168 +0,0 @@
package response
import (
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/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
}
-167
View File
@@ -1,167 +0,0 @@
package response
import (
"errors"
"fmt"
"io"
"net/http"
"ruben/inventory2/internal/consts"
"github.com/gin-gonic/gin"
)
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
@@ -1,122 +0,0 @@
package server
import (
"context"
"net/http"
"path"
"strings"
"github.com/gin-gonic/gin"
"ruben/inventory2/internal/domains/accounts"
"ruben/inventory2/internal/domains/authentication"
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events"
"ruben/inventory2/internal/logging"
"ruben/inventory2/internal/server/api"
"ruben/inventory2/internal/server/auth"
"ruben/inventory2/internal/server/response"
"ruben/inventory2/internal/server/sse"
"ruben/inventory2/internal/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
@@ -1,160 +0,0 @@
package sse
import (
"context"
"errors"
"fmt"
"path"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"ruben/inventory2/internal/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
@@ -1,135 +0,0 @@
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
@@ -1,327 +0,0 @@
package ui
import (
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"strconv"
"strings"
"ruben/inventory2/internal/domains/accounts"
etsy_platform "ruben/inventory2/internal/domains/platforms/etsy"
"ruben/inventory2/internal/domains/raw_events"
"ruben/inventory2/internal/logging"
"ruben/inventory2/internal/server/auth"
"ruben/inventory2/internal/server/response"
"github.com/angelbeltran/templater"
"github.com/gin-gonic/gin"
)
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
}