Files
inventory-plus-plus/internal/domains/platforms/etsy/etsy.go
T

313 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package etsy
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"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"
)
//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 *slog.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 *slog.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)
}
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
}