diff --git a/providers/paypal/connector.go b/providers/paypal/connector.go index 8d9505f84..ccefc1dce 100644 --- a/providers/paypal/connector.go +++ b/providers/paypal/connector.go @@ -8,6 +8,7 @@ import ( "github.com/amp-labs/connectors/internal/components/operations" "github.com/amp-labs/connectors/internal/components/reader" "github.com/amp-labs/connectors/internal/components/schema" + "github.com/amp-labs/connectors/internal/components/writer" "github.com/amp-labs/connectors/internal/staticschema" "github.com/amp-labs/connectors/providers" "github.com/amp-labs/connectors/tools/fileconv" @@ -35,6 +36,7 @@ type Connector struct { // Supported operations components.SchemaProvider components.Reader + components.Writer } func NewConnector(params common.ConnectorParams) (*Connector, error) { @@ -50,9 +52,14 @@ func constructor(base *components.Connector) (*Connector, error) { connector.SchemaProvider = schema.NewOpenAPISchemaProvider(common.ModuleRoot, schemas) + registry, err := components.NewEndpointRegistry(supportedOperations()) + if err != nil { + return nil, err + } + connector.Reader = reader.NewHTTPReader( connector.HTTPClient().Client, - components.NewEmptyEndpointRegistry(), + registry, common.ModuleRoot, operations.ReadHandlers{ BuildRequest: connector.buildReadRequest, @@ -61,5 +68,16 @@ func constructor(base *components.Connector) (*Connector, error) { }, ) + connector.Writer = writer.NewHTTPWriter( + connector.HTTPClient().Client, + registry, + common.ModuleRoot, + operations.WriteHandlers{ + BuildRequest: connector.buildWriteRequest, + ParseResponse: connector.parseWriteResponse, + ErrorHandler: common.InterpretError, + }, + ) + return connector, nil } diff --git a/providers/paypal/handlers.go b/providers/paypal/handlers.go index 67d77f651..ca3537c83 100644 --- a/providers/paypal/handlers.go +++ b/providers/paypal/handlers.go @@ -1,7 +1,9 @@ package paypal import ( + "bytes" "context" + "encoding/json" "net/http" "strconv" "time" @@ -9,6 +11,7 @@ import ( "github.com/amp-labs/connectors/common" "github.com/amp-labs/connectors/common/urlbuilder" "github.com/amp-labs/connectors/internal/datautils" + "github.com/amp-labs/connectors/internal/jsonquery" ) type timeFilter struct { @@ -95,3 +98,93 @@ func (c *Connector) parseReadResponse( params.Fields, ) } + +// objectUpdateMethod maps objects to their HTTP update method. Defaults to PATCH. +// +//nolint:gochecknoglobals +var objectUpdateMethod = datautils.NewDefaultMap( + map[string]string{ + "invoices": http.MethodPut, + "templates": http.MethodPut, + "web-profiles": http.MethodPut, + }, + func(_ string) string { + return http.MethodPatch + }, +) + +// objectWritePath holds write paths for objects that are not in the read schema (write-only). +// +//nolint:gochecknoglobals +var objectWritePath = map[string]string{ + "orders": "/v2/checkout/orders", +} + +func (c *Connector) buildWriteRequest(ctx context.Context, params common.WriteParams) (*http.Request, error) { + method := http.MethodPost + + path, err := schemas.FindURLPath(common.ModuleRoot, params.ObjectName) + if err != nil { + var ok bool + + path, ok = objectWritePath[params.ObjectName] + if !ok { + return nil, err + } + } + + url, err := urlbuilder.New(c.ProviderInfo().BaseURL, path) + if err != nil { + return nil, err + } + + if params.RecordId != "" { + url.AddPath(params.RecordId) + method = objectUpdateMethod.Get(params.ObjectName) + } + + jsonData, err := json.Marshal(params.RecordData) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, method, url.String(), bytes.NewReader(jsonData)) + if err != nil { + return nil, err + } + + return req, nil +} + +func (c *Connector) parseWriteResponse( + ctx context.Context, + params common.WriteParams, + request *http.Request, + response *common.JSONHTTPResponse, +) (*common.WriteResult, error) { + body, ok := response.Body() + if !ok { + return &common.WriteResult{Success: true}, nil + } + + recordID, err := jsonquery.New(body).StringOptional("id") + if err != nil { + return nil, err + } + + resp, err := jsonquery.Convertor.ObjectToMap(body) + if err != nil { + return nil, err + } + + id := "" + if recordID != nil { + id = *recordID + } + + return &common.WriteResult{ + RecordId: id, + Success: true, + Data: resp, + }, nil +} diff --git a/providers/paypal/support.go b/providers/paypal/support.go new file mode 100644 index 000000000..5ac0022e3 --- /dev/null +++ b/providers/paypal/support.go @@ -0,0 +1,48 @@ +package paypal + +import ( + "fmt" + "strings" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/internal/components" +) + +func supportedOperations() components.EndpointRegistryInput { + readObjects := []string{ + "balances", + "disputes", + "invoices", + "plans", + "products", + "templates", + "transactions", + "web-profiles", + "webhooks", + "webhooks-event-types", + "webhooks-events", + } + + writeObjects := []string{ + "invoices", + "orders", + "plans", + "products", + "templates", + "web-profiles", + "webhooks", + } + + return components.EndpointRegistryInput{ + common.ModuleRoot: { + { + Endpoint: fmt.Sprintf("{%s}", strings.Join(readObjects, ",")), + Support: components.ReadSupport, + }, + { + Endpoint: fmt.Sprintf("{%s}", strings.Join(writeObjects, ",")), + Support: components.WriteSupport, + }, + }, + } +} diff --git a/test/paypal/write/main.go b/test/paypal/write/main.go new file mode 100644 index 000000000..b9d91f992 --- /dev/null +++ b/test/paypal/write/main.go @@ -0,0 +1,250 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/amp-labs/connectors/common" + "github.com/amp-labs/connectors/providers/paypal" + paypalTest "github.com/amp-labs/connectors/test/paypal" + "github.com/amp-labs/connectors/test/utils" +) + +func main() { + ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer done() + + utils.SetupLogging() + + conn := paypalTest.GetPayPalConnector(ctx) + + if err := run(ctx, conn); err != nil { + utils.Fail("write test failed", "error", err) + } +} + +func run(ctx context.Context, conn *paypal.Connector) error { + // Products: create then patch-update. + productID, err := testCreatingProduct(ctx, conn) + if err != nil { + return err + } + + if err = testUpdatingProduct(ctx, conn, productID); err != nil { + return err + } + + // Plans: create (requires a product) then patch-update. + planID, err := testCreatingPlan(ctx, conn, productID) + if err != nil { + return err + } + + if err = testUpdatingPlan(ctx, conn, planID); err != nil { + return err + } + + // Invoices: create then full-replace update (PUT). + invoiceID, err := testCreatingInvoice(ctx, conn) + if err != nil { + return err + } + + if err = testUpdatingInvoice(ctx, conn, invoiceID); err != nil { + return err + } + + // Orders: create only (update requires buyer approval flow). + if err = testCreatingOrder(ctx, conn); err != nil { + return err + } + + return nil +} + +func testCreatingProduct(ctx context.Context, conn *paypal.Connector) (string, error) { + slog.Info("Creating product...") + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "products", + RecordData: map[string]any{ + "name": "Test Product", + "description": "Created by write test", + "type": "SERVICE", + "category": "SOFTWARE", + }, + }) + if err != nil { + return "", err + } + + slog.Info("Created product", "id", res.RecordId) + utils.DumpJSON(res, os.Stdout) + + return res.RecordId, nil +} + +func testUpdatingProduct(ctx context.Context, conn *paypal.Connector, productID string) error { + slog.Info("Updating product...", "id", productID) + + // PayPal products use JSON Patch format for updates. + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "products", + RecordId: productID, + RecordData: []map[string]any{ + {"op": "replace", "path": "/description", "value": "Updated by write test"}, + }, + }) + if err != nil { + return err + } + + slog.Info("Updated product", "id", productID) + utils.DumpJSON(res, os.Stdout) + + return nil +} + +func testCreatingPlan(ctx context.Context, conn *paypal.Connector, productID string) (string, error) { + slog.Info("Creating plan...", "product_id", productID) + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "plans", + RecordData: map[string]any{ + "product_id": productID, + "name": "Test Plan", + "billing_cycles": []map[string]any{ + { + "frequency": map[string]any{"interval_unit": "MONTH", "interval_count": 1}, + "tenure_type": "REGULAR", + "sequence": 1, + "total_cycles": 12, + "pricing_scheme": map[string]any{"fixed_price": map[string]any{"value": "9.99", "currency_code": "USD"}}, + }, + }, + "payment_preferences": map[string]any{ + "auto_bill_outstanding": true, + "setup_fee_failure_action": "CONTINUE", + "payment_failure_threshold": 3, + }, + }, + }) + if err != nil { + return "", err + } + + slog.Info("Created plan", "id", res.RecordId) + utils.DumpJSON(res, os.Stdout) + + return res.RecordId, nil +} + +func testUpdatingPlan(ctx context.Context, conn *paypal.Connector, planID string) error { + slog.Info("Updating plan...", "id", planID) + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "plans", + RecordId: planID, + RecordData: []map[string]any{ + {"op": "replace", "path": "/name", "value": "Updated Test Plan"}, + }, + }) + if err != nil { + return err + } + + slog.Info("Updated plan", "id", planID) + utils.DumpJSON(res, os.Stdout) + + return nil +} + +func testCreatingInvoice(ctx context.Context, conn *paypal.Connector) (string, error) { + slog.Info("Creating invoice...") + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "invoices", + RecordData: map[string]any{ + "detail": map[string]any{ + "currency_code": "USD", + "note": "Created by write test", + }, + "items": []map[string]any{ + { + "name": "Consulting", + "unit_amount": map[string]any{"currency_code": "USD", "value": "100.00"}, + "quantity": "1", + }, + }, + }, + }) + if err != nil { + return "", err + } + + slog.Info("Created invoice", "id", res.RecordId) + utils.DumpJSON(res, os.Stdout) + + return res.RecordId, nil +} + +func testUpdatingInvoice(ctx context.Context, conn *paypal.Connector, invoiceID string) error { + slog.Info("Updating invoice...", "id", invoiceID) + + // Invoice update uses PUT (full replacement). + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "invoices", + RecordId: invoiceID, + RecordData: map[string]any{ + "detail": map[string]any{ + "currency_code": "USD", + "note": "Updated by write test", + }, + "items": []map[string]any{ + { + "name": "Consulting", + "unit_amount": map[string]any{"currency_code": "USD", "value": "150.00"}, + "quantity": "1", + }, + }, + }, + }) + if err != nil { + return err + } + + slog.Info("Updated invoice", "id", invoiceID) + utils.DumpJSON(res, os.Stdout) + + return nil +} + +func testCreatingOrder(ctx context.Context, conn *paypal.Connector) error { + slog.Info("Creating order...") + + res, err := conn.Write(ctx, common.WriteParams{ + ObjectName: "orders", + RecordData: map[string]any{ + "intent": "CAPTURE", + "purchase_units": []map[string]any{ + { + "amount": map[string]any{ + "currency_code": "USD", + "value": "10.00", + }, + }, + }, + }, + }) + if err != nil { + return err + } + + slog.Info("Created order", "id", res.RecordId) + utils.DumpJSON(res, os.Stdout) + + return nil +}