309 lines
10 KiB
Go
309 lines
10 KiB
Go
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 member’s 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
|
||
}
|