Skip to content

Commit

Permalink
V3: Add initial version of new OpenAPI generator (#602)
Browse files Browse the repository at this point in the history
# Description
Aim of this change is to directly generate the new go bindings from the
OpenAPI spec with our homemade generator.
This let us able to solve all the blockers we have with the current
version and the https://github.com/deepmap/oapi-codegen generator.

The Goal of this change, is to generate code close as possible to the
Spec, with a ready to use, nice user experience.

---------

Signed-off-by: Pierre-Emmanuel Jacquier <[email protected]>
  • Loading branch information
pierre-emmanuelJ authored Dec 12, 2023
1 parent edf05b9 commit 91fb6d3
Show file tree
Hide file tree
Showing 382 changed files with 81,254 additions and 2 deletions.
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,12 @@ oapigen: install-oapi-codegen
cd v2/oapi/; go generate
@rm v2/oapi/source.json
ls -l v2/oapi/oapi.gen.go

.PHONY: generate
generate:
@wget -q --show-progress --progress=dot https://openapi-v2.exoscale.com/source.yaml -O- > v3/generator/source.yaml
@echo
@cd v3/generator/; go generate
@go mod tidy && go mod vendor
@rm v3/generator/source.yaml
@ls -l v3/*.go
16 changes: 15 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,20 +1,34 @@
module github.com/exoscale/egoscale

require (
github.com/BluntSporks/abbreviation v0.0.0-20150522120346-096cdb48bafa
github.com/deepmap/oapi-codegen v1.9.1
github.com/go-playground/validator/v10 v10.9.0
github.com/gofrs/uuid v4.4.0+incompatible
github.com/google/uuid v1.3.1
github.com/hashicorp/go-retryablehttp v0.7.1
github.com/pb33f/libopenapi v0.11.0
github.com/stretchr/testify v1.8.2
)

require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.1 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect
golang.org/x/exp v0.0.0-20230811145659-89c5cff77bcb // indirect
golang.org/x/sync v0.1.0 // indirect
golang.org/x/sys v0.1.0 // indirect
golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

go 1.17
go 1.20

retract v1.19.0 // Published accidentally.
90 changes: 90 additions & 0 deletions go.sum

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions v3/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Egoscale v3

**Egoscale v3** is based on a generator written from scratch with [libopenapi](https://github.com/pb33f/libopenapi).

The core base of the generator is using libopenapi to parse and read the [Exoscale OpenAPI spec](https://openapi-v2.exoscale.com/source.yaml) and then generate the code from it.

## Generate Egoscale v3

From the root repo
```Bash
make generate
```
17 changes: 17 additions & 0 deletions v3/api/error.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package api

import "errors"

var (
// ErrNotFound represents an error indicating a non-existent resource.
ErrNotFound = errors.New("resource not found")

// ErrTooManyFound represents an error indicating multiple results found for a single resource.
ErrTooManyFound = errors.New("multiple resources found")

// ErrInvalidRequest represents an error indicating that the caller's request is invalid.
ErrInvalidRequest = errors.New("invalid request")

// ErrAPIError represents an error indicating an API-side issue.
ErrAPIError = errors.New("API error")
)
96 changes: 96 additions & 0 deletions v3/api/middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package api

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httputil"
"os"
)

// ErrorHandlerMiddleware is an Exoscale API HTTP client middleware that
// returns concrete Go errors according to API response errors.
type ErrorHandlerMiddleware struct {
next http.RoundTripper
}

func NewAPIErrorHandlerMiddleware(next http.RoundTripper) *ErrorHandlerMiddleware {
if next == nil {
next = http.DefaultTransport
}

return &ErrorHandlerMiddleware{next: next}
}

func (m *ErrorHandlerMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := m.next.RoundTrip(req)
if err != nil {
// If the request returned a Go error don't bother analyzing the response
// body, as there probably won't be any (e.g. connection timeout/refused).
return resp, err
}

if resp.StatusCode >= 400 && resp.StatusCode <= 599 {
var res struct {
Message string `json:"message"`
}

data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %s", err)
}

if json.Valid(data) {
if err = json.Unmarshal(data, &res); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %s", err)
}
} else {
res.Message = string(data)
}

switch {
case resp.StatusCode == http.StatusNotFound:
return nil, ErrNotFound

case resp.StatusCode >= 400 && resp.StatusCode < 500:
return nil, fmt.Errorf("%w: %s", ErrInvalidRequest, res.Message)

case resp.StatusCode >= 500:
return nil, fmt.Errorf("%w: %s", ErrAPIError, res.Message)
}
}

return resp, err
}

// TraceMiddleware is a client HTTP middleware that dumps HTTP requests and responses content.
type TraceMiddleware struct {
next http.RoundTripper
}

func NewTraceMiddleware(next http.RoundTripper) *TraceMiddleware {
if next == nil {
next = http.DefaultTransport
}

return &TraceMiddleware{next: next}
}

func (t *TraceMiddleware) RoundTrip(req *http.Request) (*http.Response, error) {
if dump, err := httputil.DumpRequest(req, true); err == nil {
fmt.Fprintf(os.Stderr, ">>> %s\n", dump)
}

fmt.Fprintln(os.Stderr, "----------------------------------------------------------------------")

resp, err := t.next.RoundTrip(req)

if resp != nil {
if dump, err := httputil.DumpResponse(resp, true); err == nil {
fmt.Fprintf(os.Stderr, "<<< %s\n", dump)
}
}

return resp, err
}
75 changes: 75 additions & 0 deletions v3/api/middleware_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package api

import (
"io"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"
)

type testHandler struct {
resStatus int
resText string
}

func (h *testHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(h.resStatus)
_, _ = w.Write([]byte(h.resText))
}

func TestErrorHandlerMiddleware_RoundTrip(t *testing.T) {
tests := []struct {
name string
handler *testHandler
testFunc func(t *testing.T, res *http.Response, err error)
}{
{
name: "ErrNotFound",
handler: &testHandler{resStatus: http.StatusNotFound},
testFunc: func(t *testing.T, res *http.Response, err error) {
require.ErrorIs(t, err, ErrNotFound)
require.Nil(t, res)
},
},
{
name: "ErrInvalidRequest",
handler: &testHandler{resStatus: http.StatusBadRequest},
testFunc: func(t *testing.T, res *http.Response, err error) {
require.ErrorIs(t, err, ErrInvalidRequest)
require.Nil(t, res)
},
},
{
name: "ErrAPIError",
handler: &testHandler{resStatus: http.StatusInternalServerError},
testFunc: func(t *testing.T, res *http.Response, err error) {
require.ErrorIs(t, err, ErrAPIError)
require.Nil(t, res)
},
},
{
name: "OK",
handler: &testHandler{resStatus: http.StatusOK, resText: "test"},
testFunc: func(t *testing.T, res *http.Response, err error) {
require.NoError(t, err)
actual, _ := io.ReadAll(res.Body)
require.Equal(t, []byte("test"), actual)
},
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
testServer := httptest.NewServer(test.handler)
defer testServer.Close()

testClient := testServer.Client()
testClient.Transport = &ErrorHandlerMiddleware{next: testClient.Transport}

res, err := testClient.Get(testServer.URL)
test.testFunc(t, res, err)
})
}
}
127 changes: 127 additions & 0 deletions v3/api/security.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package api

import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
)

// SecurityProviderExoscale represents an Exoscale public API security
// provider.
type SecurityProviderExoscale struct {
// ReqExpire represents the request expiration duration.
ReqExpire time.Duration

apiKey string
apiSecret string
}

// NewSecurityProvider returns a new Exoscale public API security
// provider to sign API requests using the specified API key/secret.
func NewSecurityProvider(apiKey, apiSecret string) (*SecurityProviderExoscale, error) {
if apiKey == "" {
return nil, errors.New("missing API key")
}

if apiSecret == "" {
return nil, errors.New("missing API secret")
}

return &SecurityProviderExoscale{
ReqExpire: 10 * time.Minute,
apiKey: apiKey,
apiSecret: apiSecret,
}, nil
}

// Intercept is an HTTP middleware that intercepts and signs client requests
// before sending them to the API endpoint.
func (s *SecurityProviderExoscale) Intercept(_ context.Context, req *http.Request) error {
return s.signRequest(req, time.Now().UTC().Add(s.ReqExpire))
}

func (s *SecurityProviderExoscale) signRequest(req *http.Request, expiration time.Time) error {
var (
sigParts []string
headerParts []string
)

// Request method/URL path
sigParts = append(sigParts, fmt.Sprintf("%s %s", req.Method, req.URL.EscapedPath()))
headerParts = append(headerParts, "EXO2-HMAC-SHA256 credential="+s.apiKey)

// Request body if present
body := ""
if req.Body != nil {
data, err := io.ReadAll(req.Body)
if err != nil {
return err
}
err = req.Body.Close()
if err != nil {
return err
}
body = string(data)
req.Body = io.NopCloser(bytes.NewReader(data))
}
sigParts = append(sigParts, body)

// Request query string parameters
// Important: this is order-sensitive, we have to have to sort parameters alphabetically to ensure signed
// values match the names listed in the "signed-query-args=" signature pragma.
signedParams, paramsValues := extractRequestParameters(req)
sigParts = append(sigParts, paramsValues)
if len(signedParams) > 0 {
headerParts = append(headerParts, "signed-query-args="+strings.Join(signedParams, ";"))
}

// Request headers -- none at the moment
// Note: the same order-sensitive caution for query string parameters applies to headers.
sigParts = append(sigParts, "")

// Request expiration date (UNIX timestamp, no line return)
sigParts = append(sigParts, fmt.Sprint(expiration.Unix()))
headerParts = append(headerParts, "expires="+fmt.Sprint(expiration.Unix()))

h := hmac.New(sha256.New, []byte(s.apiSecret))
if _, err := h.Write([]byte(strings.Join(sigParts, "\n"))); err != nil {
return err
}
headerParts = append(headerParts, "signature="+base64.StdEncoding.EncodeToString(h.Sum(nil)))

req.Header.Set("Authorization", strings.Join(headerParts, ","))

return nil
}

// extractRequestParameters returns the list of request URL parameters names
// and a strings concatenating the values of the parameters.
func extractRequestParameters(req *http.Request) ([]string, string) {
var (
names []string
values string
)

for param, values := range req.URL.Query() {
// Keep only parameters that hold exactly 1 value (i.e. no empty or multi-valued parameters)
if len(values) == 1 {
names = append(names, param)
}
}
sort.Strings(names)

for _, param := range names {
values += req.URL.Query().Get(param)
}

return names, values
}
Loading

0 comments on commit 91fb6d3

Please sign in to comment.