Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,34 @@ func main() {
}
```

## Validate Support

V2 supports validating a test payload against the Synthetics API without saving the test
or triggering a run. Validate calls always respond `HTTP 200` and return a `ValidateResponse`
with `Valid`, `Message`, and `Details` (field-level errors, empty when `Valid` is `true`).
Use `ValidateResponse.FieldErrors()` to get a `map[string][]string` of field-level errors
regardless of whether the API returned an empty array or an object for `Details`.

Two flows are supported per test type:
- **Create-style** (`ValidateNew*`): validates a payload as if creating a new test.
- **Update-style** (`Validate*(id, ...)`): validates a payload as if updating the existing
test identified by `id`.

| Test type | Create-style | Update-style |
| --- | --- | --- |
| API | `ValidateNewApiCheckV2` | `ValidateApiCheckV2` |
| Browser | `ValidateNewBrowserCheckV2` | `ValidateBrowserCheckV2` |
| HTTP | `ValidateNewHttpCheckV2` / `ValidateNewHttpCheckV2WithNullablePort` | `ValidateHttpCheckV2` / `ValidateHttpCheckV2WithNullablePort` |
| Port | `ValidateNewPortCheckV2` | `ValidatePortCheckV2` |
| SSL | `ValidateNewSslCheckV2` | `ValidateSslCheckV2` |

SSL update-style validation takes a `SslCheckV2UpdateInput`, matching `UpdateSslCheckV2`'s
partial-update semantics, while SSL create-style validation takes a `SslCheckV2Input`.

Validate support is not available for the deprecated V1 client, and there is no Synthetics
API validate endpoint for Location, Variable, TOTP variable, downtime configuration, CA
certificate, or client certificate resources.

## API Documentation
API Docs are [available here](https://dev.splunk.com/observability/reference)

Expand Down
67 changes: 67 additions & 0 deletions syntheticsclientv2/validate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright 2026 Splunk, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package syntheticsclientv2

import (
"encoding/json"
"strings"
)

// ValidateResponse is the response returned by the Synthetics API's test
// validate endpoints (POST .../validate for new test definitions and
// PUT/PATCH .../{id}/validate for existing tests). Validate checks whether a
// test payload would be accepted by the API without saving any changes or
// triggering a test run.
//
// The API always responds HTTP 200 for validate calls, using Valid to signal
// whether the payload passed. Details is a JSON array when Valid is true and
// a JSON object of per-attribute error messages when Valid is false; use
// FieldErrors to normalize both shapes.
type ValidateResponse struct {
Valid bool `json:"valid"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
}

func parseValidateResponse(response string) (*ValidateResponse, error) {
var validateResponse ValidateResponse
if response == "" {
return &validateResponse, nil
}

if err := json.Unmarshal([]byte(response), &validateResponse); err != nil {
return nil, err
}

return &validateResponse, nil
}

// FieldErrors returns field-level validation error messages keyed by
// attribute name. It returns an empty map when validation succeeded, since
// the API responds with an empty array rather than an object in that case.
func (v *ValidateResponse) FieldErrors() (map[string][]string, error) {
fieldErrors := map[string][]string{}

trimmed := strings.TrimSpace(string(v.Details))
if trimmed == "" || trimmed == "null" || strings.HasPrefix(trimmed, "[") {
return fieldErrors, nil
}

if err := json.Unmarshal(v.Details, &fieldErrors); err != nil {
return nil, err
}

return fieldErrors, nil
}
80 changes: 80 additions & 0 deletions syntheticsclientv2/validate_apicheckv2.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright 2026 Splunk, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package syntheticsclientv2

import (
"bytes"
"encoding/json"
"fmt"
)

// ValidateNewApiCheckV2 checks whether an API check payload would be accepted
// when creating a new test, without saving the test or triggering a run.
func (c Client) ValidateNewApiCheckV2(ApiCheckV2Details *ApiCheckV2Input) (*ValidateResponse, *RequestDetails, error) {
if ApiCheckV2Details.Test.Requests[0].Setup == nil {
ApiCheckV2Details.Test.Requests[0].Setup = make([]Setup, 0)
}

if ApiCheckV2Details.Test.Requests[0].Validations == nil {
ApiCheckV2Details.Test.Requests[0].Validations = make([]Validations, 0)
}

body, err := json.Marshal(ApiCheckV2Details)
if err != nil {
return nil, nil, err
}

details, err := c.makePublicAPICall("POST", "/v2/tests/api/validate", bytes.NewBuffer(body), nil)
if err != nil {
return nil, details, err
}

validateApiCheckV2, err := parseValidateResponse(details.ResponseBody)
if err != nil {
return nil, details, err
}

return validateApiCheckV2, details, nil
}

// ValidateApiCheckV2 checks whether an API check payload would be accepted
// when updating the existing test identified by id, without saving the
// change or triggering a run.
func (c Client) ValidateApiCheckV2(id int, ApiCheckV2Details *ApiCheckV2Input) (*ValidateResponse, *RequestDetails, error) {
if ApiCheckV2Details.Test.Requests[0].Setup == nil {
ApiCheckV2Details.Test.Requests[0].Setup = make([]Setup, 0)
}

if ApiCheckV2Details.Test.Requests[0].Validations == nil {
ApiCheckV2Details.Test.Requests[0].Validations = make([]Validations, 0)
}

body, err := json.Marshal(ApiCheckV2Details)
if err != nil {
return nil, nil, err
}

details, err := c.makePublicAPICall("PUT", fmt.Sprintf("/v2/tests/api/%d/validate", id), bytes.NewBuffer(body), nil)
if err != nil {
return nil, details, err
}

validateApiCheckV2, err := parseValidateResponse(details.ResponseBody)
if err != nil {
return nil, details, err
}

return validateApiCheckV2, details, nil
}
151 changes: 151 additions & 0 deletions syntheticsclientv2/validate_apicheckv2_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
//go:build unit_tests
// +build unit_tests

// Copyright 2026 Splunk, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package syntheticsclientv2

import (
"encoding/json"
"net/http"
"testing"
)

func TestValidateNewApiCheckV2Success(t *testing.T) {
setup()
defer teardown()

input := ApiCheckV2Input{}
err := json.Unmarshal([]byte(createApiV2Body), &input)
if err != nil {
t.Fatal(err)
}

testMux.HandleFunc("/v2/tests/api/validate", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
_, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`))
if err != nil {
t.Fatal(err)
}
})

resp, details, err := testClient.ValidateNewApiCheckV2(&input)
if err != nil {
t.Fatal(err)
}
if details == nil {
t.Fatal("expected request details")
}
if !resp.Valid {
t.Errorf("resp.Valid = %#v, want true", resp.Valid)
}
}

func TestValidateNewApiCheckV2Failure(t *testing.T) {
setup()
defer teardown()

input := ApiCheckV2Input{}
err := json.Unmarshal([]byte(createApiV2Body), &input)
if err != nil {
t.Fatal(err)
}

testMux.HandleFunc("/v2/tests/api/validate", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "POST")
_, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"name":["can't be blank"]}}`))
if err != nil {
t.Fatal(err)
}
})

resp, _, err := testClient.ValidateNewApiCheckV2(&input)
if err != nil {
t.Fatal(err)
}
if resp.Valid {
t.Errorf("resp.Valid = %#v, want false", resp.Valid)
}

fieldErrors, err := resp.FieldErrors()
if err != nil {
t.Fatal(err)
}
if len(fieldErrors["name"]) != 1 || fieldErrors["name"][0] != "can't be blank" {
t.Errorf("FieldErrors()[\"name\"] = %#v, want [\"can't be blank\"]", fieldErrors["name"])
}
}

func TestValidateApiCheckV2Success(t *testing.T) {
setup()
defer teardown()

input := ApiCheckV2Input{}
err := json.Unmarshal([]byte(createApiV2Body), &input)
if err != nil {
t.Fatal(err)
}

testMux.HandleFunc("/v2/tests/api/489/validate", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "PUT")
_, err := w.Write([]byte(`{"valid":true,"message":"Test is valid","details":[]}`))
if err != nil {
t.Fatal(err)
}
})

resp, _, err := testClient.ValidateApiCheckV2(489, &input)
if err != nil {
t.Fatal(err)
}
if !resp.Valid {
t.Errorf("resp.Valid = %#v, want true", resp.Valid)
}
}

func TestValidateApiCheckV2Failure(t *testing.T) {
setup()
defer teardown()

input := ApiCheckV2Input{}
err := json.Unmarshal([]byte(createApiV2Body), &input)
if err != nil {
t.Fatal(err)
}

testMux.HandleFunc("/v2/tests/api/489/validate", func(w http.ResponseWriter, r *http.Request) {
testMethod(t, r, "PUT")
_, err := w.Write([]byte(`{"valid":false,"message":"Test is invalid","details":{"requests":["is invalid"]}}`))
if err != nil {
t.Fatal(err)
}
})

resp, _, err := testClient.ValidateApiCheckV2(489, &input)
if err != nil {
t.Fatal(err)
}
if resp.Valid {
t.Errorf("resp.Valid = %#v, want false", resp.Valid)
}

fieldErrors, err := resp.FieldErrors()
if err != nil {
t.Fatal(err)
}
if len(fieldErrors["requests"]) != 1 || fieldErrors["requests"][0] != "is invalid" {
t.Errorf("FieldErrors()[\"requests\"] = %#v, want [\"is invalid\"]", fieldErrors["requests"])
}
}
Loading
Loading