From 02640451224b9bddc4a6e43b08aca3e985547df3 Mon Sep 17 00:00:00 2001 From: till Date: Sat, 11 Mar 2023 12:41:22 +0100 Subject: [PATCH 1/2] Fix: person/contact creation - make fields optional (to fix encoding of the struct) - make more fields optional when they can be --- README.md | 20 ++++++++++---------- contacts.go | 42 +++++++++++++++++++++--------------------- contacts_test.go | 5 +++-- 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 1827fbb..09b91ae 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,10 @@ body := golexoffice.ContactBody{ "", 0, golexoffice.ContactBodyRoles{ - golexoffice.ContactBodyCustomer{}, - golexoffice.ContactBodyVendor{}, + &golexoffice.ContactBodyCustomer{}, + &golexoffice.ContactBodyVendor{}, }, - golexoffice.ContactBodyCompany{ + &golexoffice.ContactBodyCompany{ "J&J Ideenschmiede GmbH", "12345/12345", "DE123456789", @@ -79,14 +79,14 @@ body := golexoffice.ContactBody{ }}, }, golexoffice.ContactBodyAddresses{ - []golexoffice.ContactBodyBilling{{ + []&golexoffice.ContactBodyBilling{{ "Rechnungsadressenzusatz", "Fährstraße 31", "21502", "Geesthacht", "DE", }}, - []golexoffice.ContactBodyShipping{{ + []&golexoffice.ContactBodyShipping{{ "Lieferadressenzusatz", "Fährstraße 31", "21502", @@ -130,15 +130,15 @@ body := golexoffice.ContactBody{ "ID", 1, golexoffice.ContactBodyRoles{ - golexoffice.ContactBodyCustomer{}, - golexoffice.ContactBodyVendor{}, + &golexoffice.ContactBodyCustomer{}, + &golexoffice.ContactBodyVendor{}, }, golexoffice.ContactBodyCompany{ "J&J Ideenschmiede GmbH", "12345/12345", "DE123456789", true, - []golexoffice.ContactBodyContactPersons{{ + []&golexoffice.ContactBodyContactPersons{{ "Herr", "Jonas", "Kwiedor", @@ -147,14 +147,14 @@ body := golexoffice.ContactBody{ }}, }, golexoffice.ContactBodyAddresses{ - []golexoffice.ContactBodyBilling{{ + []&golexoffice.ContactBodyBilling{{ "Rechnungsadressenzusatz", "Fährstraße 31", "21502", "Geesthacht", "DE", }}, - []golexoffice.ContactBodyShipping{{ + []&golexoffice.ContactBodyShipping{{ "Lieferadressenzusatz", "Fährstraße 31", "21502", diff --git a/contacts.go b/contacts.go index 2dab8b8..72cd403 100644 --- a/contacts.go +++ b/contacts.go @@ -32,8 +32,8 @@ type ContactsReturnContent struct { Id string `json:"id,omitempty"` Version int `json:"version,omitempty"` Roles ContactBodyRoles `json:"roles"` - Company ContactBodyCompany `json:"company,omitempty"` - Person ContactBodyPerson `json:"person,omitempty"` + Company *ContactBodyCompany `json:"company,omitempty"` + Person *ContactBodyPerson `json:"person,omitempty"` Addresses ContactBodyAddresses `json:"addresses"` EmailAddresses ContactBodyEmailAddresses `json:"emailAddresses"` PhoneNumbers ContactBodyPhoneNumbers `json:"phoneNumbers"` @@ -117,21 +117,21 @@ type ContactsReturnSort struct { // ContactBody is to create a new contact type ContactBody struct { - Id string `json:"id,omitempty"` - Version int `json:"version,omitempty"` - Roles ContactBodyRoles `json:"roles"` - Company ContactBodyCompany `json:"company,omitempty"` - Person ContactBodyPerson `json:"person,omitempty"` - Addresses ContactBodyAddresses `json:"addresses"` - EmailAddresses ContactBodyEmailAddresses `json:"emailAddresses"` - PhoneNumbers ContactBodyPhoneNumbers `json:"phoneNumbers"` - Note string `json:"note"` - Archived bool `json:"archived,omitempty"` + Id string `json:"id,omitempty"` + Version int `json:"version"` + Roles ContactBodyRoles `json:"roles"` + Company *ContactBodyCompany `json:"company,omitempty"` + Person *ContactBodyPerson `json:"person,omitempty"` + Addresses *ContactBodyAddresses `json:"addresses,omitempty"` + EmailAddresses *ContactBodyEmailAddresses `json:"emailAddresses,omitempty"` + PhoneNumbers *ContactBodyPhoneNumbers `json:"phoneNumbers,omitempty"` + Note string `json:"note"` + Archived bool `json:"archived,omitempty"` } type ContactBodyRoles struct { - Customer ContactBodyCustomer `json:"customer"` - Vendor ContactBodyVendor `json:"vendor"` + Customer *ContactBodyCustomer `json:"customer,omitempty"` + Vendor *ContactBodyVendor `json:"vendor,omitempty"` } type ContactBodyCustomer struct { @@ -143,11 +143,11 @@ type ContactBodyVendor struct { } type ContactBodyCompany struct { - Name string `json:"name"` - TaxNumber string `json:"taxNumber"` - VatRegistrationId string `json:"vatRegistrationId"` - AllowTaxFreeInvoices bool `json:"allowTaxFreeInvoices"` - ContactPersons []ContactBodyContactPersons `json:"contactPersons"` + Name string `json:"name"` + TaxNumber string `json:"taxNumber,omitempty"` + VatRegistrationId string `json:"vatRegistrationId,omitempty"` + AllowTaxFreeInvoices bool `json:"allowTaxFreeInvoices"` + ContactPersons []*ContactBodyContactPersons `json:"contactPersons"` } type ContactBodyPerson struct { @@ -165,8 +165,8 @@ type ContactBodyContactPersons struct { } type ContactBodyAddresses struct { - Billing []ContactBodyBilling `json:"billing"` - Shipping []ContactBodyShipping `json:"shipping"` + Billing []*ContactBodyBilling `json:"billing"` + Shipping []*ContactBodyShipping `json:"shipping"` } type ContactBodyBilling struct { diff --git a/contacts_test.go b/contacts_test.go index d09a664..4f5eb44 100644 --- a/contacts_test.go +++ b/contacts_test.go @@ -18,10 +18,11 @@ func TestAddContact(t *testing.T) { config.SetBaseUrl(server.URL) resp, err := config.AddContact(golexoffice.ContactBody{ + Version: 0, Roles: golexoffice.ContactBodyRoles{ - Customer: golexoffice.ContactBodyCustomer{}, + Customer: &golexoffice.ContactBodyCustomer{}, }, - Person: golexoffice.ContactBodyPerson{ + Person: &golexoffice.ContactBodyPerson{ FirstName: "Thomas", LastName: "Mustermann", }, From 47012f4d401b789f0780d28445e19d79b6425188 Mon Sep 17 00:00:00 2001 From: till Date: Sat, 11 Mar 2023 14:33:52 +0100 Subject: [PATCH 2/2] Fix: handle errors from the API - check if response is successful (before decoding) - parse error response(s) - add basic test coverage for legacy and new error responses - upgrade go to 1.20 --- config.go | 52 ++++++++++++++++++++++++++++++++-- errors.go | 52 ++++++++++++++++++++++++++++++++++ errors_test.go | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 2 +- 4 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 errors.go create mode 100644 errors_test.go diff --git a/config.go b/config.go index 403f170..1248a97 100644 --- a/config.go +++ b/config.go @@ -10,8 +10,12 @@ package golexoffice import ( + "encoding/json" + "errors" + "fmt" "io" "net/http" + "strings" ) const ( @@ -68,7 +72,51 @@ func (c *Config) Send(path string, body io.Reader, method, contentType string) ( return nil, err } - // Return data - return response, nil + if isSuccessful(response) { + // Return data + return response, nil + } + + // TODO(till): revisit parsing when we add more API endpoints + if strings.Contains(url, "invoices") { + return nil, parseErrorResponse(response) + } + + return nil, parseLegacyErrorResponse(response) +} + +func isSuccessful(response *http.Response) bool { + return response.StatusCode < 400 +} + +func parseErrorResponse(response *http.Response) error { + var errorResp ErrorResponse + err := json.NewDecoder(response.Body).Decode(&errorResp) + if err != nil { + return fmt.Errorf("decoding error while unpacking response: %s", err) + } + + var keep []error + for _, detail := range errorResp.Details { + keep = append(keep, fmt.Errorf( + "field: %s (%s): %s", detail.Field, detail.Violation, detail.Message, + )) + } + + return errors.Join(keep...) +} +func parseLegacyErrorResponse(response *http.Response) error { + var errorResp LegacyErrorResponse + err := json.NewDecoder(response.Body).Decode(&errorResp) + if err != nil { + return fmt.Errorf("decoding error while unpacking response: %s", err) + } + + // potentially multiple issues returned from the LexOffice API + var keep []error + for _, issue := range errorResp.IssueList { + keep = append(keep, fmt.Errorf("key: %s (%s): %s", issue.Key, issue.Source, issue.Type)) + } + return errors.Join(keep...) } diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..ad145c7 --- /dev/null +++ b/errors.go @@ -0,0 +1,52 @@ +package golexoffice + +// source: https://developers.lexoffice.io/docs/#error-codes-legacy-error-response +// files, profile, contacts +// +// { +// "requestId":"3fb21ee4-ad26-4e2f-82af-a1197af02d08", +// "IssueList":[ +// {"i18nKey":"invalid_value","source":"company and person","type":"validation_failure"}, +// {"i18nKey":"missing_entity","source":"company.name","type":"validation_failure"} +// ] +// } +type LegacyErrorResponse struct { + RequestId string `json:"requestId"` + IssueList []struct { + Key string `json:"i18nKey"` + Source string `json:"source"` + Type string `json:"type"` + } `json:"IssueList"` +} + +// source: https://developers.lexoffice.io/docs/#error-codes-regular-error-response +// event-subscription, invoices +// +// { +// "timestamp": "2017-05-11T17:12:31.233+02:00", +// "status": 406, +// "error": "Not Acceptable", +// "path": "/v1/invoices", +// "traceId": "90d78d0777be", +// "message": "Validation failed for request. Please see details list for specific causes.", +// "details": [ +// { +// "violation": "NOTNULL", +// "field": "lineItems[0].unitPrice.taxRatePercentage", +// "message": "darf nicht leer sein" +// } +// ] +// } +type ErrorResponse struct { + Timestamp string `json:"timestamp"` // FIXME: should be a date thing + Status int `json:"status"` + Error string `json:"error"` + Path string `json:"path"` + TraceID string `json:"traceId"` + Message string `json:"message"` + Details []struct { + Violation string `json:"violation"` + Field string `json:"field"` + Message string `json:"message"` + } `json:"details"` +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..eb97e24 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,77 @@ +package golexoffice_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/hostwithquantum/golexoffice" + "github.com/stretchr/testify/assert" +) + +// {"requestId":"3fb21ee4-ad26-4e2f-82af-a1197af02d08","IssueList":[{"i18nKey":"invalid_value","source":"company and person","type":"validation_failure"},{"i18nKey":"missing_entity","source":"company.name","type":"validation_failure"}]} + +// {"requestId":"75d4dad6-6ccb-40fd-8c22-797f2d421d98","IssueList":[{"i18nKey":"missing_entity","source":"company.vatRegistrationId","type":"validation_failure"},{"i18nKey":"missing_entity","source":"company.taxNumber","type":"validation_failure"}]} + +func TestErrorResponse(t *testing.T) { + server := errorMock() + defer server.Close() + + lexOffice := golexoffice.NewConfig("token", nil) + lexOffice.SetBaseUrl(server.URL) + + t.Run("errors=legacy", func(t *testing.T) { + _, err := lexOffice.AddContact(golexoffice.ContactBody{ + Company: &golexoffice.ContactBodyCompany{ + Name: "company", + VatRegistrationId: "", + TaxNumber: "", + }, + }) + assert.Error(t, err) + }) + + t.Run("errors=new", func(t *testing.T) { + _, err := lexOffice.AddInvoice(golexoffice.InvoiceBody{}) + assert.Error(t, err) + }) + +} + +func errorMock() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/contacts" { + w.WriteHeader(http.StatusBadRequest) + //nolint:errcheck + w.Write([]byte(`{ + "requestId":"75d4dad6-6ccb-40fd-8c22-797f2d421d98", + "IssueList":[ + {"i18nKey":"missing_entity","source":"company.vatRegistrationId","type":"validation_failure"}, + {"i18nKey":"missing_entity","source":"company.taxNumber","type":"validation_failure"} + ] + }`)) + return + } + if r.URL.Path == "/v1/invoices" { + w.WriteHeader(http.StatusNotAcceptable) + //nolint:errcheck + w.Write([]byte(`{ + "timestamp": "2017-05-11T17:12:31.233+02:00", + "status": 406, + "error": "Not Acceptable", + "path": "/v1/invoices", + "traceId": "90d78d0777be", + "message": "Validation failed for request. Please see details list for specific causes.", + "details": [ + { + "violation": "NOTNULL", + "field": "lineItems[0].unitPrice.taxRatePercentage", + "message": "darf nicht leer sein" + } + ] + }`)) + return + } + w.WriteHeader(http.StatusNotFound) + })) +} diff --git a/go.mod b/go.mod index 25f341d..e12fe15 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/hostwithquantum/golexoffice -go 1.19 +go 1.20 require github.com/stretchr/testify v1.8.2