account page stubbed: link to etsy sign up

This commit is contained in:
2025-12-29 06:06:13 -07:00
parent 61f9afb39c
commit c9100383e4
35 changed files with 1326 additions and 158 deletions
+47
View File
@@ -0,0 +1,47 @@
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
}
+310 -9
View File
@@ -1,19 +1,320 @@
package etsy
//go:generate oapi-codegen -package generated_client -o generated_client/client.go openapi.3.0.2.json
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
const (
etsyAPIKeystring = "38ncokqh0jih5jshfk8iv4n5"
etsyAPISharedSecret = "jaaw0tyizf"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"ruben/inventory2/internal/domains/platforms/etsy/generated_client"
)
func GetEtsyAPIKeystring() string {
return etsyAPIKeystring
//go:generate oapi-codegen -generate types,client -package generated_client -o generated_client/client.go openapi.3.0.2.json
// 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 {
oAuthRedirectURI func(acctID int64) string
apiKeystring string
apiSharedSecret string
db *pgxpool.Pool
}
PlatformWithContext struct {
ctx context.Context
p *Platform
}
)
// 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(oAuthRedirectURI func(acctID int64) string, apiKeystring, apiSharedSecret string, db *pgxpool.Pool) *Platform {
return &Platform{
oAuthRedirectURI: oAuthRedirectURI,
apiKeystring: apiKeystring,
apiSharedSecret: apiSharedSecret,
db: db,
}
}
func GetEtsyAPISharedSecret() string {
return etsyAPISharedSecret
func (p *Platform) WithContext(ctx context.Context) *PlatformWithContext {
return &PlatformWithContext{
ctx: ctx,
p: p,
}
}
func requestAnAuthCode() {
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
}
func (p *PlatformWithContext) GenerateConnectionURLForNewAccount(acctID int64) (*url.URL, error) {
return p.p.GenerateConnectionURLForNewAccount(p.ctx, acctID)
}
// 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)
}
fmt.Println("new access token:", accessToken)
fmt.Println("new refresh token:", refreshToken)
fmt.Println("new token expiration:", expiration)
// 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
}
+273
View File
@@ -0,0 +1,273 @@
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 *PlatformWithContext) GetUserPointerByAccountID(acctID int64) (*EtsyUser, error) {
return p.p.GetUserPointerByAccountID(p.ctx, acctID)
}
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)
}