diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a52ec3b..7aa945e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,18 @@ name: CI on: push: - branches: - - main - pull_request: - branches: - - main - - next + branches-ignore: + - 'generated' + - 'codegen/**' + - 'integrated/**' + - 'stl-preview-head/**' + - 'stl-preview-base/**' jobs: lint: + timeout-minutes: 10 name: lint - runs-on: ubuntu-latest - + runs-on: ${{ github.repository == 'stainless-sdks/oxp-go' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v4 @@ -25,9 +25,9 @@ jobs: - name: Run lints run: ./scripts/lint test: + timeout-minutes: 10 name: test - runs-on: ubuntu-latest - + runs-on: ${{ github.repository == 'stainless-sdks/oxp-go' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} steps: - uses: actions/checkout@v4 diff --git a/.release-please-manifest.json b/.release-please-manifest.json index c7159c1..3d2ac0b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.0.2" + ".": "0.1.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 3cca198..e5884e5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,2 +1,4 @@ configured_endpoints: 3 openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/oxp%2Foxp-40d99ea9c623ae3faf290164274b8d9a85b078d85a71718afba833e7218502a8.yml +openapi_spec_hash: 0e452b918e63bb906a2886dfc2109a82 +config_hash: 4267724890db00da87d77d1a951b86f4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 95db213..00dbe6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # Changelog +## 0.1.0 (2025-05-14) + +Full Changelog: [v0.0.2...v0.1.0](https://github.com/OpenExecProtocol/oxp-go/compare/v0.0.2...v0.1.0) + +### Features + +* **client:** add support for endpoint-specific base URLs in python ([3435e12](https://github.com/OpenExecProtocol/oxp-go/commit/3435e12d250ba62a5fb9d2fba16fe91e97b789e4)) +* **client:** add support for reading base URL from environment variable ([da52f00](https://github.com/OpenExecProtocol/oxp-go/commit/da52f0027c05cc6be43fd7293589dfc7ebb14837)) +* **client:** support custom http clients ([#11](https://github.com/OpenExecProtocol/oxp-go/issues/11)) ([b7b678c](https://github.com/OpenExecProtocol/oxp-go/commit/b7b678c5f4d9c146dd23871c0662b9ef5403ad5b)) + + +### Bug Fixes + +* **client:** clean up reader resources ([02e8b5f](https://github.com/OpenExecProtocol/oxp-go/commit/02e8b5fb02559a8c9add291312b9f9877cae7f53)) +* **client:** correctly update body in WithJSONSet ([3190e17](https://github.com/OpenExecProtocol/oxp-go/commit/3190e17794373f3889eb2c28c8c0f90de3fd3ac4)) +* **client:** return error on bad custom url instead of panic ([#10](https://github.com/OpenExecProtocol/oxp-go/issues/10)) ([8edf5ec](https://github.com/OpenExecProtocol/oxp-go/commit/8edf5eced0206863e5ae85870a85cff7e548d087)) +* handle empty bodies in WithJSONSet ([43b0734](https://github.com/OpenExecProtocol/oxp-go/commit/43b073417aa7257ab842d7ce7859f29f780905a1)) +* pluralize `list` response variables ([#9](https://github.com/OpenExecProtocol/oxp-go/issues/9)) ([2f32d03](https://github.com/OpenExecProtocol/oxp-go/commit/2f32d03bbadb69b873b8c57ac444b09d467d32dc)) +* **test:** return early after test failure ([#7](https://github.com/OpenExecProtocol/oxp-go/issues/7)) ([e163f84](https://github.com/OpenExecProtocol/oxp-go/commit/e163f841e7943677016c18bd60f21de10a0eaa0e)) + + +### Chores + +* add request options to client tests ([#6](https://github.com/OpenExecProtocol/oxp-go/issues/6)) ([e478bcd](https://github.com/OpenExecProtocol/oxp-go/commit/e478bcd482587882013395d08abd22df061b4d12)) +* **ci:** add timeout thresholds for CI jobs ([482d0da](https://github.com/OpenExecProtocol/oxp-go/commit/482d0dac6399b0e7816505e79db2336e7796f43c)) +* **ci:** only use depot for staging repos ([142cc65](https://github.com/OpenExecProtocol/oxp-go/commit/142cc659c7157b718fec883d9985c89d8076d2c7)) +* **docs:** document pre-request options ([20574c5](https://github.com/OpenExecProtocol/oxp-go/commit/20574c517ada77d8e9cc532842090674609b704e)) +* **docs:** improve security documentation ([#4](https://github.com/OpenExecProtocol/oxp-go/issues/4)) ([9a98381](https://github.com/OpenExecProtocol/oxp-go/commit/9a98381e85ec1abb6297303695a01bf5b0bb0806)) +* fix typos ([#8](https://github.com/OpenExecProtocol/oxp-go/issues/8)) ([a327a76](https://github.com/OpenExecProtocol/oxp-go/commit/a327a76a39b0c7ea966e429d769f96cda6086c5d)) +* **internal:** codegen related update ([63aa3d5](https://github.com/OpenExecProtocol/oxp-go/commit/63aa3d57eb4c8ce13fa441d9e0f416ba8aee5f3e)) +* **internal:** codegen related update ([d36116e](https://github.com/OpenExecProtocol/oxp-go/commit/d36116ef0fbe894a5e4e8b23e3c38a8524b4883d)) +* **internal:** expand CI branch coverage ([825a88c](https://github.com/OpenExecProtocol/oxp-go/commit/825a88c183de7e804d22b0e0e34531bdddeefe03)) +* **internal:** reduce CI branch coverage ([2f052f3](https://github.com/OpenExecProtocol/oxp-go/commit/2f052f3bc440f8df016ec45fd2a324748e427267)) + + +### Documentation + +* update documentation links to be more uniform ([04176d5](https://github.com/OpenExecProtocol/oxp-go/commit/04176d5fae7f921179aac873854a2ee02fd09cf0)) + ## 0.0.2 (2025-03-16) Full Changelog: [v0.0.1-alpha.0...v0.0.2](https://github.com/OpenExecProtocol/oxp-go/compare/v0.0.1-alpha.0...v0.0.2) diff --git a/README.md b/README.md index 5d77223..1722ee7 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ Go Reference -The Oxp Go library provides convenient access to [the Oxp REST -API](https://openexecprotocol.org) from applications written in Go. The full API of this library can be found in [api.md](api.md). +The Oxp Go library provides convenient access to the [Oxp REST API](https://openexecprotocol.org) +from applications written in Go. It is generated with [Stainless](https://www.stainless.com/). @@ -24,7 +24,7 @@ Or to pin the version: ```sh -go get -u 'github.com/OpenExecProtocol/oxp-go@v0.0.2' +go get -u 'github.com/OpenExecProtocol/oxp-go@v0.1.0' ``` @@ -52,11 +52,11 @@ func main() { client := oxp.NewClient( option.WithBearerToken("My Bearer Token"), // defaults to os.LookupEnv("OXP_API_KEY") ) - tool, err := client.Tools.List(context.TODO(), oxp.ToolListParams{}) + tools, err := client.Tools.List(context.TODO(), oxp.ToolListParams{}) if err != nil { panic(err.Error()) } - fmt.Printf("%+v\n", tool.Items) + fmt.Printf("%+v\n", tools.Items) } ``` @@ -250,7 +250,7 @@ you need to examine response headers, status codes, or other details. ```go // Create a variable to store the HTTP response var response *http.Response -tool, err := client.Tools.List( +tools, err := client.Tools.List( context.TODO(), oxp.ToolListParams{}, option.WithResponseInto(&response), @@ -258,7 +258,7 @@ tool, err := client.Tools.List( if err != nil { // handle error } -fmt.Printf("%+v\n", tool) +fmt.Printf("%+v\n", tools) fmt.Printf("Status Code: %d\n", response.StatusCode) fmt.Printf("Headers: %+#v\n", response.Header) diff --git a/client.go b/client.go index b217573..6af46ef 100644 --- a/client.go +++ b/client.go @@ -20,10 +20,13 @@ type Client struct { Tools *ToolService } -// DefaultClientOptions read from the environment (OXP_API_KEY). This should be -// used to initialize new clients. +// DefaultClientOptions read from the environment (OXP_API_KEY, OXP_BASE_URL). This +// should be used to initialize new clients. func DefaultClientOptions() []option.RequestOption { defaults := []option.RequestOption{option.WithEnvironmentProduction()} + if o, ok := os.LookupEnv("OXP_BASE_URL"); ok { + defaults = append(defaults, option.WithBaseURL(o)) + } if o, ok := os.LookupEnv("OXP_API_KEY"); ok { defaults = append(defaults, option.WithBearerToken(o)) } @@ -31,9 +34,9 @@ func DefaultClientOptions() []option.RequestOption { } // NewClient generates a new client with the default option read from the -// environment (OXP_API_KEY). The option passed in as arguments are applied after -// these default arguments, and all option will be passed down to the services and -// requests that this client makes. +// environment (OXP_API_KEY, OXP_BASE_URL). The option passed in as arguments are +// applied after these default arguments, and all option will be passed down to the +// services and requests that this client makes. func NewClient(opts ...option.RequestOption) (r *Client) { opts = append(DefaultClientOptions(), opts...) diff --git a/client_test.go b/client_test.go index 8a4e459..ac0bbfc 100644 --- a/client_test.go +++ b/client_test.go @@ -26,6 +26,7 @@ func (t *closureTransport) RoundTrip(req *http.Request) (*http.Response, error) func TestUserAgentHeader(t *testing.T) { var userAgent string client := oxp.NewClient( + option.WithBearerToken("My Bearer Token"), option.WithHTTPClient(&http.Client{ Transport: &closureTransport{ fn: func(req *http.Request) (*http.Response, error) { @@ -46,6 +47,7 @@ func TestUserAgentHeader(t *testing.T) { func TestRetryAfter(t *testing.T) { retryCountHeaders := make([]string, 0) client := oxp.NewClient( + option.WithBearerToken("My Bearer Token"), option.WithHTTPClient(&http.Client{ Transport: &closureTransport{ fn: func(req *http.Request) (*http.Response, error) { @@ -79,6 +81,7 @@ func TestRetryAfter(t *testing.T) { func TestDeleteRetryCountHeader(t *testing.T) { retryCountHeaders := make([]string, 0) client := oxp.NewClient( + option.WithBearerToken("My Bearer Token"), option.WithHTTPClient(&http.Client{ Transport: &closureTransport{ fn: func(req *http.Request) (*http.Response, error) { @@ -108,6 +111,7 @@ func TestDeleteRetryCountHeader(t *testing.T) { func TestOverwriteRetryCountHeader(t *testing.T) { retryCountHeaders := make([]string, 0) client := oxp.NewClient( + option.WithBearerToken("My Bearer Token"), option.WithHTTPClient(&http.Client{ Transport: &closureTransport{ fn: func(req *http.Request) (*http.Response, error) { @@ -137,6 +141,7 @@ func TestOverwriteRetryCountHeader(t *testing.T) { func TestRetryAfterMs(t *testing.T) { attempts := 0 client := oxp.NewClient( + option.WithBearerToken("My Bearer Token"), option.WithHTTPClient(&http.Client{ Transport: &closureTransport{ fn: func(req *http.Request) (*http.Response, error) { @@ -162,6 +167,7 @@ func TestRetryAfterMs(t *testing.T) { func TestContextCancel(t *testing.T) { client := oxp.NewClient( + option.WithBearerToken("My Bearer Token"), option.WithHTTPClient(&http.Client{ Transport: &closureTransport{ fn: func(req *http.Request) (*http.Response, error) { @@ -181,6 +187,7 @@ func TestContextCancel(t *testing.T) { func TestContextCancelDelay(t *testing.T) { client := oxp.NewClient( + option.WithBearerToken("My Bearer Token"), option.WithHTTPClient(&http.Client{ Transport: &closureTransport{ fn: func(req *http.Request) (*http.Response, error) { @@ -208,6 +215,7 @@ func TestContextDeadline(t *testing.T) { go func() { client := oxp.NewClient( + option.WithBearerToken("My Bearer Token"), option.WithHTTPClient(&http.Client{ Transport: &closureTransport{ fn: func(req *http.Request) (*http.Response, error) { diff --git a/internal/requestconfig/requestconfig.go b/internal/requestconfig/requestconfig.go index 93e10fe..a2d4e19 100644 --- a/internal/requestconfig/requestconfig.go +++ b/internal/requestconfig/requestconfig.go @@ -192,6 +192,12 @@ func UseDefaultParam[T any](dst *param.Field[T], src *T) { } } +// This interface is primarily used to describe an [*http.Client], but also +// supports custom HTTP implementations. +type HTTPDoer interface { + Do(req *http.Request) (*http.Response, error) +} + // RequestConfig represents all the state related to one request. // // Editing the variables inside RequestConfig directly is unstable api. Prefer @@ -202,6 +208,10 @@ type RequestConfig struct { Context context.Context Request *http.Request BaseURL *url.URL + // DefaultBaseURL will be used if BaseURL is not explicitly overridden using + // WithBaseURL. + DefaultBaseURL *url.URL + CustomHTTPDoer HTTPDoer HTTPClient *http.Client Middlewares []middleware BearerToken string @@ -241,7 +251,7 @@ func shouldRetry(req *http.Request, res *http.Response) bool { return true } - // If the header explictly wants a retry behavior, respect that over the + // If the header explicitly wants a retry behavior, respect that over the // http status code. if res.Header.Get("x-should-retry") == "true" { return true @@ -367,7 +377,11 @@ func retryDelay(res *http.Response, retryCount int) time.Duration { func (cfg *RequestConfig) Execute() (err error) { if cfg.BaseURL == nil { - return fmt.Errorf("requestconfig: base url is not set") + if cfg.DefaultBaseURL != nil { + cfg.BaseURL = cfg.DefaultBaseURL + } else { + return fmt.Errorf("requestconfig: base url is not set") + } } cfg.Request.URL, err = cfg.BaseURL.Parse(strings.TrimLeft(cfg.Request.URL.String(), "/")) @@ -399,6 +413,9 @@ func (cfg *RequestConfig) Execute() (err error) { } handler := cfg.HTTPClient.Do + if cfg.CustomHTTPDoer != nil { + handler = cfg.CustomHTTPDoer.Do + } for i := len(cfg.Middlewares) - 1; i >= 0; i -= 1 { handler = applyMiddleware(cfg.Middlewares[i], handler) } @@ -498,6 +515,7 @@ func (cfg *RequestConfig) Execute() (err error) { } contents, err := io.ReadAll(res.Body) + res.Body.Close() if err != nil { return fmt.Errorf("error reading response body: %w", err) } @@ -579,17 +597,35 @@ func (cfg *RequestConfig) Apply(opts ...RequestOption) error { return nil } +// PreRequestOptions is used to collect all the options which need to be known before +// a call to [RequestConfig.ExecuteNewRequest], such as path parameters +// or global defaults. +// PreRequestOptions will return a [RequestConfig] with the options applied. +// +// Only request option functions of type [PreRequestOptionFunc] are applied. func PreRequestOptions(opts ...RequestOption) (RequestConfig, error) { cfg := RequestConfig{} for _, opt := range opts { - if _, ok := opt.(PreRequestOptionFunc); !ok { - continue + if opt, ok := opt.(PreRequestOptionFunc); ok { + err := opt.Apply(&cfg) + if err != nil { + return cfg, err + } } + } + return cfg, nil +} - err := opt.Apply(&cfg) +// WithDefaultBaseURL returns a RequestOption that sets the client's default Base URL. +// This is always overridden by setting a base URL with WithBaseURL. +// WithBaseURL should be used instead of WithDefaultBaseURL except in internal code. +func WithDefaultBaseURL(baseURL string) RequestOption { + u, err := url.Parse(baseURL) + return RequestOptionFunc(func(r *RequestConfig) error { if err != nil { - return cfg, err + return err } - } - return cfg, nil + r.DefaultBaseURL = u + return nil + }) } diff --git a/internal/version.go b/internal/version.go index 9b92696..02eac73 100644 --- a/internal/version.go +++ b/internal/version.go @@ -2,4 +2,4 @@ package internal -const PackageVersion = "0.0.2" // x-release-please-version +const PackageVersion = "0.1.0" // x-release-please-version diff --git a/option/requestoption.go b/option/requestoption.go index db9b540..a067e5e 100644 --- a/option/requestoption.go +++ b/option/requestoption.go @@ -6,7 +6,6 @@ import ( "bytes" "fmt" "io" - "log" "net/http" "net/url" "strings" @@ -24,12 +23,15 @@ import ( type RequestOption = requestconfig.RequestOption // WithBaseURL returns a RequestOption that sets the BaseURL for the client. +// +// For security reasons, ensure that the base URL is trusted. func WithBaseURL(base string) RequestOption { u, err := url.Parse(base) - if err != nil { - log.Fatalf("failed to parse BaseURL: %s\n", err) - } return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { + if err != nil { + return fmt.Errorf("requestoption: WithBaseURL failed to parse url %s\n", err) + } + if u.Path != "" && !strings.HasSuffix(u.Path, "/") { u.Path += "/" } @@ -38,11 +40,34 @@ func WithBaseURL(base string) RequestOption { }) } -// WithHTTPClient returns a RequestOption that changes the underlying [http.Client] used to make this +// HTTPClient is primarily used to describe an [*http.Client], but also +// supports custom implementations. +// +// For bespoke implementations, prefer using an [*http.Client] with a +// custom transport. See [http.RoundTripper] for further information. +type HTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +// WithHTTPClient returns a RequestOption that changes the underlying http client used to make this // request, which by default is [http.DefaultClient]. -func WithHTTPClient(client *http.Client) RequestOption { +// +// For custom uses cases, it is recommended to provide an [*http.Client] with a custom +// [http.RoundTripper] as its transport, rather than directly implementing [HTTPClient]. +func WithHTTPClient(client HTTPClient) RequestOption { return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) error { - r.HTTPClient = client + if client == nil { + return fmt.Errorf("requestoption: custom http client cannot be nil") + } + + if c, ok := client.(*http.Client); ok { + // Prefer the native client if possible. + r.HTTPClient = c + r.CustomHTTPDoer = nil + } else { + r.CustomHTTPDoer = client + } + return nil }) } @@ -144,17 +169,25 @@ func WithQueryDel(key string) RequestOption { // [sjson format]: https://github.com/tidwall/sjson func WithJSONSet(key string, value interface{}) RequestOption { return requestconfig.RequestOptionFunc(func(r *requestconfig.RequestConfig) (err error) { - if buffer, ok := r.Body.(*bytes.Buffer); ok { - b := buffer.Bytes() + var b []byte + + if r.Body == nil { + b, err = sjson.SetBytes(nil, key, value) + if err != nil { + return err + } + } else if buffer, ok := r.Body.(*bytes.Buffer); ok { + b = buffer.Bytes() b, err = sjson.SetBytes(b, key, value) if err != nil { return err } - r.Body = bytes.NewBuffer(b) - return nil + } else { + return fmt.Errorf("cannot use WithJSONSet on a body that is not serialized as *bytes.Buffer") } - return fmt.Errorf("cannot use WithJSONSet on a body that is not serialized as *bytes.Buffer") + r.Body = bytes.NewBuffer(b) + return nil }) } @@ -229,7 +262,7 @@ func WithRequestTimeout(dur time.Duration) RequestOption { // environment to be the "production" environment. An environment specifies which base URL // to use by default. func WithEnvironmentProduction() RequestOption { - return WithBaseURL("https://api.arcade.dev/") + return requestconfig.WithDefaultBaseURL("https://api.arcade.dev/") } // WithBearerToken returns a RequestOption that sets the client setting "bearer_token". diff --git a/tool.go b/tool.go index 223a8bb..070ba21 100644 --- a/tool.go +++ b/tool.go @@ -368,6 +368,10 @@ func init() { apijson.RegisterUnion( reflect.TypeOf((*ToolCallResponseResultObjectValueUnion)(nil)).Elem(), "", + apijson.UnionVariant{ + TypeFilter: gjson.JSON, + Type: reflect.TypeOf(ToolCallResponseResultObjectValueMap{}), + }, apijson.UnionVariant{ TypeFilter: gjson.JSON, Type: reflect.TypeOf(ToolCallResponseResultObjectValueArray{}), diff --git a/usage_test.go b/usage_test.go index c366a7c..535d53a 100644 --- a/usage_test.go +++ b/usage_test.go @@ -24,9 +24,10 @@ func TestUsage(t *testing.T) { option.WithBaseURL(baseURL), option.WithBearerToken("My Bearer Token"), ) - tool, err := client.Tools.List(context.TODO(), oxp.ToolListParams{}) + tools, err := client.Tools.List(context.TODO(), oxp.ToolListParams{}) if err != nil { t.Error(err) + return } - t.Logf("%+v\n", tool.Items) + t.Logf("%+v\n", tools.Items) }