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: .: %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 }