diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cdc540a..688807c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,13 @@ repos: - repo: https://github.com/tekwizely/pre-commit-golang - rev: master + rev: v1.0.0-rc.1 hooks: - id: go-fumpt - id: go-mod-tidy - id: go-lint - id: go-imports - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 + rev: v4.4.0 hooks: - id: check-yaml - id: end-of-file-fixer diff --git a/README.md b/README.md index 9bd7a01..5a59559 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ import "github.com/NdoleStudio/lemonsqueezy-go" ## Implemented - **Subscriptions** + - `PATCH /v1/subscriptions/:id`: Update a subscription - `GET /v1/subscriptions/:id`: Retrieve a subscription + - `GET /v1/subscriptions`: List all subscriptions - `DELETE /v1/subscriptions/{id}`: Cancel an active subscription - **Webhooks** - `Verify`: Verify that webhook requests are coming from Lemon Squeezy diff --git a/client.go b/client.go index cf039a9..beebc9e 100644 --- a/client.go +++ b/client.go @@ -22,8 +22,8 @@ type Client struct { apiKey string signingSecret string - Webhooks *webhooksService - Subscriptions *subscriptionsService + Webhooks *WebhooksService + Subscriptions *SubscriptionsService } // New creates and returns a new Client from a slice of Option. @@ -42,8 +42,8 @@ func New(options ...Option) *Client { } client.common.client = client - client.Subscriptions = (*subscriptionsService)(&client.common) - client.Webhooks = (*webhooksService)(&client.common) + client.Subscriptions = (*SubscriptionsService)(&client.common) + client.Webhooks = (*WebhooksService)(&client.common) return client } diff --git a/contracts.go b/contracts.go index 77d6325..fae30b4 100644 --- a/contracts.go +++ b/contracts.go @@ -1,19 +1,34 @@ package client // ApiResponse represents an API response -type ApiResponse[T any] struct { - Jsonapi ApiResponseJSONAPI `json:"jsonapi"` - Links ApiResponseLink `json:"links"` - Data ApiResponseData[T] `json:"data"` +type ApiResponse[T any, R any] struct { + Jsonapi ApiResponseJSONAPI `json:"jsonapi"` + Links ApiResponseLink `json:"links"` + Data ApiResponseData[T, R] `json:"data"` +} + +// ApiResponseList represents an API response with a list of items +type ApiResponseList[T any, R any] struct { + Jsonapi ApiResponseJSONAPI `json:"jsonapi"` + Links ApiResponseListLink `json:"links"` + Meta ApiResponseListMeta `json:"meta"` + Data []ApiResponseData[T, R] `json:"data"` } // ApiResponseData contains the api response data -type ApiResponseData[T any] struct { - Type string `json:"type"` - ID string `json:"id"` - Attributes T `json:"attributes"` - Relationships ApiResponseRelationships `json:"relationships"` - Links ApiResponseLink `json:"links"` +type ApiResponseData[T any, R any] struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes T `json:"attributes"` + Relationships R `json:"relationships"` + Links ApiResponseLink `json:"links"` +} + +// Resource returns a lemonsqueezy resource. It's similar to ApiResponseData but without the links +type Resource[T any] struct { + Type string `json:"type"` + ID string `json:"id"` + Attributes T `json:"attributes"` } // ApiResponseJSONAPI API version @@ -28,15 +43,27 @@ type ApiResponseLinks struct { // ApiResponseLink defines a link type ApiResponseLink struct { - Related string `json:"related"` + Related string `json:"related,omitempty"` Self string `json:"self"` } -// ApiResponseRelationships relationships of an object -type ApiResponseRelationships struct { - Store ApiResponseLinks `json:"store"` - Order ApiResponseLinks `json:"order"` - OrderItem ApiResponseLinks `json:"order-item"` - Product ApiResponseLinks `json:"product"` - Variant ApiResponseLinks `json:"variant"` +// ApiResponseListLink defines a link for list os resources +type ApiResponseListLink struct { + First string `json:"first"` + Last string `json:"last"` +} + +// ApiResponseListMeta defines the meta data for a list api response +type ApiResponseListMeta struct { + Page ApiResponseListMetaPage `json:"page"` +} + +// ApiResponseListMetaPage defines the pagination meta data for a list api response +type ApiResponseListMetaPage struct { + CurrentPage int `json:"currentPage"` + From int `json:"from"` + LastPage int `json:"lastPage"` + PerPage int `json:"perPage"` + To int `json:"to"` + Total int `json:"total"` } diff --git a/internal/helpers/test_helper.go b/internal/helpers/test_helper.go index f647a1f..d8650ee 100644 --- a/internal/helpers/test_helper.go +++ b/internal/helpers/test_helper.go @@ -1,11 +1,12 @@ package helpers import ( - "bytes" - "context" - "io" + "encoding/json" "net/http" "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" ) // MakeTestServer creates an api server for testing @@ -19,25 +20,9 @@ func MakeTestServer(responseCode int, body []byte) *httptest.Server { })) } -// MakeRequestCapturingTestServer creates an api server that captures the request object -func MakeRequestCapturingTestServer(responseCode int, response []byte, request *http.Request) *httptest.Server { - return httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, req *http.Request) { - clonedRequest := req.Clone(context.Background()) - - // clone body - body, err := io.ReadAll(req.Body) - if err != nil { - panic(err) - } - req.Body = io.NopCloser(bytes.NewReader(body)) - clonedRequest.Body = io.NopCloser(bytes.NewReader(body)) - - *request = *clonedRequest - - responseWriter.WriteHeader(responseCode) - _, err = responseWriter.Write(response) - if err != nil { - panic(err) - } - })) +// AssertObjectEqualsJSON checks if the JSON representation of an object matches the expected value +func AssertObjectEqualsJSON(t *testing.T, expectedJSON []byte, actual any) { + actualJSON, err := json.Marshal(actual) + assert.Nil(t, err) + assert.JSONEq(t, string(expectedJSON), string(actualJSON)) } diff --git a/internal/stubs/subscriptions.go b/internal/stubs/subscriptions.go index 80c7384..d9083f1 100644 --- a/internal/stubs/subscriptions.go +++ b/internal/stubs/subscriptions.go @@ -78,6 +78,196 @@ func SubscriptionGetResponse() []byte { `) } +// SubscriptionUpdateResponse is a dummy response to the PATCH /v1/subscriptions/:id endpoint +func SubscriptionUpdateResponse() []byte { + return []byte(` +{ + "type": "subscriptions", + "id": "1", + "attributes": { + "store_id": 1, + "order_id": 1, + "order_item_id": 1, + "product_id": 9, + "variant_id": 11, + "product_name": "New Plan Product", + "variant_name": "New Plan Variant", + "user_name": "Darlene Daugherty", + "user_email": "gernser@yahoo.com", + "status": "active", + "status_formatted": "Active", + "pause": null, + "cancelled": false, + "trial_ends_at": null, + "billing_anchor": 29, + "urls": { + "update_payment_method": "https://app.lemonsqueezy.com/my-orders/2ba92a4e-a00a-45d2-a128-16856ffa8cdf/subscription/8/update-payment-method?expires=1666869343&signature=9985e3bf9007840aeb3951412be475abc17439c449c1af3e56e08e45e1345413" + }, + "renews_at": "2022-11-12T00:00:00.000000Z", + "ends_at": null, + "created_at": "2021-08-11T13:47:27.000000Z", + "updated_at": "2021-08-11T13:54:19.000000Z", + "test_mode": false + } +} +`) +} + +// SubscriptionsListResponse returns a list of subscription responses ordered by created at +func SubscriptionsListResponse() []byte { + return []byte(` +{ + "meta":{ + "page":{ + "currentPage":1, + "from":1, + "lastPage":1, + "perPage":10, + "to":10, + "total":10 + } + }, + "jsonapi":{ + "version":"1.0" + }, + "links":{ + "first":"https://api.lemonsqueezy.com/v1/subscriptions?page%5Bnumber%5D=1&page%5Bsize%5D=10&sort=-createdAt", + "last":"https://api.lemonsqueezy.com/v1/subscriptions?page%5Bnumber%5D=1&page%5Bsize%5D=10&sort=-createdAt" + }, + "data":[ + { + "type":"subscriptions", + "id":"1", + "attributes":{ + "store_id":1, + "order_id":1, + "order_item_id":1, + "product_id":1, + "variant_id":1, + "product_name":"Example Product", + "variant_name":"Example Variant", + "user_name":"Darlene Daugherty", + "user_email":"gernser@yahoo.com", + "status":"active", + "status_formatted":"Active", + "pause":null, + "cancelled":false, + "trial_ends_at":null, + "billing_anchor":12, + "urls":{ + "update_payment_method":"https://app.lemonsqueezy.com/my-orders/2ba92a4e-a00a-45d2-a128-16856ffa8cdf/subscription/8/update-payment-method?expires=1666869343&signature=9985e3bf9007840aeb3951412be475abc17439c449c1af3e56e08e45e1345413" + }, + "renews_at":"2022-11-12T00:00:00.000000Z", + "ends_at":null, + "created_at":"2021-08-11T13:47:27.000000Z", + "updated_at":"2021-08-11T13:54:19.000000Z", + "test_mode":false + }, + "relationships":{ + "store":{ + "links":{ + "related":"https://api.lemonsqueezy.com/v1/subscriptions/1/store", + "self":"https://api.lemonsqueezy.com/v1/subscriptions/1/relationships/store" + } + }, + "order":{ + "links":{ + "related":"https://api.lemonsqueezy.com/v1/subscriptions/1/order", + "self":"https://api.lemonsqueezy.com/v1/subscriptions/1/relationships/order" + } + }, + "order-item":{ + "links":{ + "related":"https://api.lemonsqueezy.com/v1/subscriptions/1/order-item", + "self":"https://api.lemonsqueezy.com/v1/subscriptions/1/relationships/order-item" + } + }, + "product":{ + "links":{ + "related":"https://api.lemonsqueezy.com/v1/subscriptions/1/product", + "self":"https://api.lemonsqueezy.com/v1/subscriptions/1/relationships/product" + } + }, + "variant":{ + "links":{ + "related":"https://api.lemonsqueezy.com/v1/subscriptions/1/variant", + "self":"https://api.lemonsqueezy.com/v1/subscriptions/1/relationships/variant" + } + } + }, + "links":{ + "self":"https://api.lemonsqueezy.com/v1/subscriptions/1" + } + }, + { + "type":"subscriptions", + "id":"2", + "attributes":{ + "store_id":2, + "order_id":2, + "order_item_id":2, + "product_id":2, + "variant_id":2, + "product_name":"Example Product 2", + "variant_name":"Example Variant 2", + "user_name":"Darlene Daugherty 2", + "user_email":"gernser2@yahoo.com", + "status":"active", + "status_formatted":"Active", + "pause":null, + "cancelled":false, + "trial_ends_at":null, + "billing_anchor":13, + "urls":{ + "update_payment_method":"https://app.lemonsqueezy.com/my-orders/2ba92a4e-a00a-45d2-a128-16856ffa8cdf/subscription/8/update-payment-method?expires=1666869343&signature=9985e3bf9007840aeb3951412be475abc17439c449c1af3e56e08e45e1345413" + }, + "renews_at":"2022-11-12T00:00:00.000000Z", + "ends_at":null, + "created_at":"2021-08-11T13:47:27.000000Z", + "updated_at":"2021-08-11T13:54:19.000000Z", + "test_mode":false + }, + "relationships":{ + "store":{ + "links":{ + "related":"https://api.lemonsqueezy.com/v1/subscriptions/1/store", + "self":"https://api.lemonsqueezy.com/v1/subscriptions/1/relationships/store" + } + }, + "order":{ + "links":{ + "related":"https://api.lemonsqueezy.com/v1/subscriptions/1/order", + "self":"https://api.lemonsqueezy.com/v1/subscriptions/1/relationships/order" + } + }, + "order-item":{ + "links":{ + "related":"https://api.lemonsqueezy.com/v1/subscriptions/1/order-item", + "self":"https://api.lemonsqueezy.com/v1/subscriptions/1/relationships/order-item" + } + }, + "product":{ + "links":{ + "related":"https://api.lemonsqueezy.com/v1/subscriptions/1/product", + "self":"https://api.lemonsqueezy.com/v1/subscriptions/1/relationships/product" + } + }, + "variant":{ + "links":{ + "related":"https://api.lemonsqueezy.com/v1/subscriptions/1/variant", + "self":"https://api.lemonsqueezy.com/v1/subscriptions/1/relationships/variant" + } + } + }, + "links":{ + "self":"https://api.lemonsqueezy.com/v1/subscriptions/1" + } + } + ] +} +`) +} + // SubscriptionCancelResponse returns a dummy response to DELETE /v1/subscriptions/:id endpoint func SubscriptionCancelResponse() []byte { return []byte(` diff --git a/subscriptions.go b/subscriptions.go index 79485bd..8c77258 100644 --- a/subscriptions.go +++ b/subscriptions.go @@ -38,3 +38,35 @@ type SubscriptionPause struct { Mode string `json:"mode"` ResumesAt time.Time `json:"resumes_at"` } + +// SubscriptionUpdateParams are parameters for updating a subscription +type SubscriptionUpdateParams struct { + Type string `json:"type"` + ID string `json:"id"` + Pause *SubscriptionPause `json:"pause,omitempty"` + Cancelled bool `json:"cancelled,omitempty"` + InvoiceImmediately bool `json:"invoice_immediately,omitempty"` + Attributes SubscriptionUpdateParamsAttributes `json:"attributes"` +} + +// SubscriptionUpdateParamsAttributes are subscription update attributes +type SubscriptionUpdateParamsAttributes struct { + ProductID int `json:"product_id"` + VariantID int `json:"variant_id"` + BillingAnchor int `json:"billing_anchor"` +} + +// ApiResponseRelationshipsSubscription relationships of a subscription object +type ApiResponseRelationshipsSubscription struct { + Store ApiResponseLinks `json:"store"` + Order ApiResponseLinks `json:"order"` + OrderItem ApiResponseLinks `json:"order-item"` + Product ApiResponseLinks `json:"product"` + Variant ApiResponseLinks `json:"variant"` +} + +// ApiResponseSubscription represents a subscription api response +type ApiResponseSubscription = ApiResponse[Subscription, ApiResponseRelationshipsSubscription] + +// ApiResponseSubscriptionList represents a list of subscription api responses. +type ApiResponseSubscriptionList = ApiResponseList[Subscription, ApiResponseRelationshipsSubscription] diff --git a/subscriptions_service.go b/subscriptions_service.go index 0c922b3..41be92c 100644 --- a/subscriptions_service.go +++ b/subscriptions_service.go @@ -6,12 +6,54 @@ import ( "net/http" ) -// subscriptionsService is the API client for the `/subscriptions` endpoint -type subscriptionsService service +// SubscriptionsService is the API client for the `/subscriptions` endpoint +type SubscriptionsService service + +// Update an existing subscription to specific parameter +// https://docs.lemonsqueezy.com/api/subscriptions#update-a-subscription +func (service *SubscriptionsService) Update(ctx context.Context, params *SubscriptionUpdateParams) (*Resource[Subscription], *Response, error) { + request, err := service.client.newRequest(ctx, http.MethodPatch, "/v1/subscriptions/"+params.ID, nil) + if err != nil { + return nil, nil, err + } + + response, err := service.client.do(request) + if err != nil { + return nil, response, err + } + + subscription := new(Resource[Subscription]) + if err = json.Unmarshal(*response.Body, subscription); err != nil { + return nil, response, err + } + + return subscription, response, nil +} + +// List returns a paginated list of subscriptions ordered by created_at (descending) +// https://docs.lemonsqueezy.com/api/subscriptions#list-all-subscriptions +func (service *SubscriptionsService) List(ctx context.Context) (*ApiResponseSubscriptionList, *Response, error) { + request, err := service.client.newRequest(ctx, http.MethodGet, "/v1/subscriptions", nil) + if err != nil { + return nil, nil, err + } + + response, err := service.client.do(request) + if err != nil { + return nil, response, err + } + + subscriptions := new(ApiResponseSubscriptionList) + if err = json.Unmarshal(*response.Body, subscriptions); err != nil { + return nil, response, err + } + + return subscriptions, response, nil +} // Get returns the subscription with the given ID. // https://docs.lemonsqueezy.com/api/subscriptions#retrieve-a-subscription -func (service *subscriptionsService) Get(ctx context.Context, subscriptionID string) (*ApiResponse[Subscription], *Response, error) { +func (service *SubscriptionsService) Get(ctx context.Context, subscriptionID string) (*ApiResponseSubscription, *Response, error) { request, err := service.client.newRequest(ctx, http.MethodGet, "/v1/subscriptions/"+subscriptionID, nil) if err != nil { return nil, nil, err @@ -22,17 +64,17 @@ func (service *subscriptionsService) Get(ctx context.Context, subscriptionID str return nil, response, err } - status := new(ApiResponse[Subscription]) - if err = json.Unmarshal(*response.Body, status); err != nil { + subscription := new(ApiResponseSubscription) + if err = json.Unmarshal(*response.Body, subscription); err != nil { return nil, response, err } - return status, response, nil + return subscription, response, nil } // Cancel an active subscription the given ID. // https://docs.lemonsqueezy.com/api/subscriptions#retrieve-a-subscription -func (service *subscriptionsService) Cancel(ctx context.Context, subscriptionID string) (*ApiResponse[Subscription], *Response, error) { +func (service *SubscriptionsService) Cancel(ctx context.Context, subscriptionID string) (*ApiResponseSubscription, *Response, error) { request, err := service.client.newRequest(ctx, http.MethodDelete, "/v1/subscriptions/"+subscriptionID, nil) if err != nil { return nil, nil, err @@ -43,10 +85,10 @@ func (service *subscriptionsService) Cancel(ctx context.Context, subscriptionID return nil, response, err } - status := new(ApiResponse[Subscription]) - if err = json.Unmarshal(*response.Body, status); err != nil { + subscription := new(ApiResponseSubscription) + if err = json.Unmarshal(*response.Body, subscription); err != nil { return nil, response, err } - return status, response, nil + return subscription, response, nil } diff --git a/subscriptions_service_test.go b/subscriptions_service_test.go index 9969f09..240da19 100644 --- a/subscriptions_service_test.go +++ b/subscriptions_service_test.go @@ -28,14 +28,14 @@ func TestSubscriptionsService_Get(t *testing.T) { assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) assert.Equal(t, stubs.SubscriptionGetResponse(), *response.Body) - assert.Equal(t, &ApiResponse[Subscription]{ + assert.Equal(t, &ApiResponseSubscription{ Jsonapi: ApiResponseJSONAPI{ Version: "1.0", }, Links: ApiResponseLink{ Self: "https://api.lemonsqueezy.com/v1/subscriptions/1", }, - Data: ApiResponseData[Subscription]{ + Data: ApiResponseData[Subscription, ApiResponseRelationshipsSubscription]{ Type: "subscriptions", ID: "1", Attributes: Subscription{ @@ -63,7 +63,7 @@ func TestSubscriptionsService_Get(t *testing.T) { UpdatedAt: time.Date(2021, time.August, 11, 13, 54, 19, 0, time.UTC), TestMode: false, }, - Relationships: ApiResponseRelationships{ + Relationships: ApiResponseRelationshipsSubscription{ Store: ApiResponseLinks{ Links: ApiResponseLink{ Related: "https://api.lemonsqueezy.com/v1/subscriptions/1/store", @@ -143,14 +143,14 @@ func TestSubscriptionsService_Cancel(t *testing.T) { assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) assert.Equal(t, stubs.SubscriptionCancelResponse(), *response.Body) - assert.Equal(t, &ApiResponse[Subscription]{ + assert.Equal(t, &ApiResponseSubscription{ Jsonapi: ApiResponseJSONAPI{ Version: "1.0", }, Links: ApiResponseLink{ Self: "https://api.lemonsqueezy.com/v1/subscriptions/1", }, - Data: ApiResponseData[Subscription]{ + Data: ApiResponseData[Subscription, ApiResponseRelationshipsSubscription]{ Type: "subscriptions", ID: "1", Attributes: Subscription{ @@ -181,7 +181,7 @@ func TestSubscriptionsService_Cancel(t *testing.T) { UpdatedAt: time.Date(2021, time.August, 11, 13, 54, 19, 0, time.UTC), TestMode: false, }, - Relationships: ApiResponseRelationships{ + Relationships: ApiResponseRelationshipsSubscription{ Store: ApiResponseLinks{ Links: ApiResponseLink{ Related: "https://api.lemonsqueezy.com/v1/subscriptions/1/store", @@ -243,3 +243,96 @@ func TestSubscriptionsService_CancelWithError(t *testing.T) { // Teardown server.Close() } + +func TestSubscriptionsService_Update(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + server := helpers.MakeTestServer(http.StatusOK, stubs.SubscriptionUpdateResponse()) + client := New(WithBaseURL(server.URL)) + + // Act + subscription, response, err := client.Subscriptions.Update(context.Background(), &SubscriptionUpdateParams{ + Type: "subscriptions", + ID: "1", + Attributes: SubscriptionUpdateParamsAttributes{ + ProductID: 9, + VariantID: 11, + BillingAnchor: 29, + }, + }) + + // Assert + assert.Nil(t, err) + + assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + assert.Equal(t, stubs.SubscriptionUpdateResponse(), *response.Body) + assert.Equal(t, "1", subscription.ID) + + // Teardown + server.Close() +} + +func TestSubscriptionsService_UpdateWithError(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + server := helpers.MakeTestServer(http.StatusInternalServerError, nil) + client := New(WithBaseURL(server.URL)) + + // Act + _, response, err := client.Subscriptions.Update(context.Background(), &SubscriptionUpdateParams{}) + + // Assert + assert.NotNil(t, err) + + assert.Equal(t, http.StatusInternalServerError, response.HTTPResponse.StatusCode) + + // Teardown + server.Close() +} + +func TestSubscriptionsService_List(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + server := helpers.MakeTestServer(http.StatusOK, stubs.SubscriptionsListResponse()) + client := New(WithBaseURL(server.URL)) + + // Act + subscriptions, response, err := client.Subscriptions.List(context.Background()) + + // Assert + assert.Nil(t, err) + + assert.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode) + assert.Equal(t, stubs.SubscriptionsListResponse(), *response.Body) + assert.Equal(t, 2, len(subscriptions.Data)) + assert.Equal(t, "2", subscriptions.Data[1].ID) + + // Teardown + server.Close() +} + +func TestSubscriptionsService_ListWithError(t *testing.T) { + // Setup + t.Parallel() + + // Arrange + server := helpers.MakeTestServer(http.StatusInternalServerError, nil) + client := New(WithBaseURL(server.URL)) + + // Act + _, response, err := client.Subscriptions.List(context.Background()) + + // Assert + assert.NotNil(t, err) + + assert.Equal(t, http.StatusInternalServerError, response.HTTPResponse.StatusCode) + + // Teardown + server.Close() +} diff --git a/webhooks_service.go b/webhooks_service.go index 4c7deb8..aca8d9c 100644 --- a/webhooks_service.go +++ b/webhooks_service.go @@ -7,12 +7,12 @@ import ( "encoding/hex" ) -// webhooksService is the used to verify the signature in webhook requests -type webhooksService service +// WebhooksService is the used to verify the signature in webhook requests +type WebhooksService service // Verify the signature in webhook requests // https://docs.lemonsqueezy.com/api/webhooks#signing-requests -func (service *webhooksService) Verify(_ context.Context, signature string, body []byte) bool { +func (service *WebhooksService) Verify(_ context.Context, signature string, body []byte) bool { key := []byte(service.client.signingSecret) h := hmac.New(sha256.New, key) h.Write(body)