Generate OpenAPI 3.0 specs directly from your Ginkgo integration tests.
Inspired by rswag: define API docs alongside executable tests using a nested DSL.
gswag wraps Ginkgo containers (Path, Get/Post/..., Response, RunTest).
- During test tree construction, it records operation metadata (path, method, params, schemas, security).
- During test execution,
RunTestmakes a real HTTP request against your test server. - Responses are asserted with Gomega and used to infer/capture examples when configured.
WriteSpec()serializes the in-memory OpenAPI document.
go get github.com/oaswrap/gswagRequires Go 1.24+.
// suite_test.go
package api_test
import (
"net/http/httptest"
"testing"
. "github.com/oaswrap/gswag"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var testServer *httptest.Server
func TestAPI(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "API Suite")
}
var _ = BeforeSuite(func() {
Init(&Config{
Title: "My API",
Version: "1.0.0",
OutputPath: "./docs/openapi.yaml",
SecuritySchemes: map[string]SecuritySchemeConfig{
"bearerAuth": BearerJWT(),
},
})
testServer = httptest.NewServer(NewRouter())
SetTestServer(testServer)
})
var _ = AfterSuite(func() {
testServer.Close()
Expect(WriteSpec()).To(Succeed())
})// users_test.go
package api_test
import (
"net/http"
. "github.com/oaswrap/gswag"
. "github.com/onsi/gomega"
)
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
var _ = Path("/users/{id}", func() {
Get("Get user by ID", func() {
Tag("users")
OperationID("getUserByID")
BearerAuth()
Parameter("id", PathParam, String)
Response(200, "user found", func() {
ResponseSchema(new(User))
SetParam("id", "1")
RunTest(func(resp *http.Response) {
Expect(resp).To(HaveStatus(http.StatusOK))
Expect(resp).To(ContainJSONKey("id"))
})
})
})
})Path("/users", func() {
Get("List users", func() { ... })
Post("Create user", func() { ... })
})
Path("/users/{id}", func() {
Get("Get user", func() { ... })
Put("Replace user", func() { ... })
Patch("Update user", func() { ... })
Delete("Delete user", func() { ... })
})Tag("users", "admin")
Description("Returns one user")
OperationID("getUser")
Deprecated()
Hidden() // run test, but do not add the operation to the specBearerAuth() // uses "bearerAuth"
Security("apiKey") // custom scheme
// Security("oauth2", "scope") // with scopes if neededParameter("id", PathParam, String)
Parameter("limit", QueryParam, Integer)
Parameter("X-Request-ID", HeaderParam, String)You can also define typed query params:
type ListQuery struct {
Search string `query:"search"`
Page int `query:"page"`
}
QueryParamStruct(new(ListQuery))RequestBody(new(CreateUserRequest))
Response(201, "created", func() {
ResponseSchema(new(User))
ResponseHeader("X-Rate-Limit", "")
SetBody(&CreateUserRequest{Name: "Alice"})
SetHeader("X-Trace-ID", "abc")
SetQueryParam("verbose", "true")
RunTest()
})Request value setters (for execution):
SetParam(name, value)SetQueryParam(name, value)SetHeader(name, value)SetBody(body)SetRawBody([]byte, contentType)
By default gswag documents request bodies as application/json. Use Consumes and Produces to override:
Post("Upload file", func() {
Consumes("multipart/form-data")
Produces("application/json")
RequestBody(new(UploadForm))
Response(200, "uploaded", func() {
SetRawBody(formData, "multipart/form-data")
RunTest(...)
})
})Produces accepts multiple types when an endpoint can serve different formats:
Get("Export data", func() {
Produces("application/json", "text/csv")
...
})Use BeforeRequest (a thin BeforeEach wrapper) to share SetParam, SetHeader, or SetBody calls across multiple Response blocks — similar to let in rswag:
Get("Get order", func() {
BeforeRequest(func() {
SetHeader("Authorization", "Bearer test-token")
})
Response(200, "found", func() {
SetParam("id", "42")
RunTest(...)
})
Response(404, "not found", func() {
SetParam("id", "999")
RunTest(...)
})
})Note:
BeforeRequestruns during test execution (GinkgoBeforeEach), so it is suited for values that can only be determined at runtime. Static values (known at test-tree build time) should be set directly inside theResponseblock.
Init(&Config{
Title: "My API", // required
Version: "1.0.0", // required
Description: "Public API",
TermsOfService: "https://example.com/terms",
Contact: &ContactConfig{
Name: "API Team",
URL: "https://example.com/support",
Email: "api@example.com",
},
License: &LicenseConfig{
Name: "Apache 2.0",
URL: "https://www.apache.org/licenses/LICENSE-2.0.html",
},
ExternalDocs: &ExternalDocsConfig{
Description: "More docs",
URL: "https://example.com/docs",
},
Tags: []TagConfig{
{Name: "users", Description: "User operations"},
},
OutputPath: "./docs/openapi.yaml", // default: ./docs/openapi.yaml
OutputFormat: YAML, // YAML or JSON
Servers: []ServerConfig{
{URL: "https://api.example.com", Description: "prod"},
},
ExcludePaths: []string{
"/internal/*",
"/admin/health",
},
SecuritySchemes: map[string]SecuritySchemeConfig{
"bearerAuth": BearerJWT(),
"apiKey": APIKeyHeader("X-API-Key"),
"oauth2": OAuth2Implicit("https://example.com/oauth/authorize", map[string]string{
"read:users": "read users",
"write:users": "modify users",
}),
},
EnforceResponseValidation: true,
ValidationMode: "warn", // "fail" (default) or "warn"
CaptureExamples: true,
MaxExampleBytes: 0, // 0 means default cap of 16384 bytes; set >0 to override
Sanitizer: func(b []byte) []byte {
return b // redact sensitive data here
},
MergeTimeout: 60 * time.Second, // how long MergeAndWriteSpec waits for slow nodes (default 30s)
})ExcludePaths supports exact path matches and simple prefix patterns ending in *.
Excluded operations are still executed by tests when you hit them through RunTest; they are only omitted from spec generation.
Security helpers:
BearerJWT()APIKeyHeader(name)APIKeyQuery(name)APIKeyCookie(name)OAuth2Implicit(authURL, scopes)
Matchers operate on *http.Response (the object passed to RunTest callback):
HaveStatus(code)HaveStatusInRange(lo, hi)HaveHeader(key, value)HaveJSONBody(expected)ContainJSONKey(key)MatchJSONSchema(model)HaveNonEmptyBody()
Example:
RunTest(func(resp *http.Response) {
Expect(resp).To(HaveStatus(200))
Expect(resp).To(HaveHeader("Content-Type", "application/json"))
Expect(resp).To(ContainJSONKey("id"))
})issues := ValidateSpec()
for _, issue := range issues {
fmt.Println(issue.String())
}issues, err := ValidateSpecFile("docs/openapi.yaml")if err := WriteAndValidateSpec(); err != nil {
// err wraps ErrSpecInvalid when error-level issues exist
panic(err)
}Validation highlights:
info.titleandinfo.versionrequired (error)- empty paths (warning)
- operation missing summary/tags (warning)
- undeclared security scheme references (error)
For ginkgo -p, each parallel process writes a partial spec and process 1 merges them all.
var _ = BeforeSuite(func() {
gswag.Init(&gswag.Config{
Title: "My API",
Version: "1.0.0",
OutputPath: "./docs/openapi.yaml",
})
testServer = httptest.NewServer(api.NewRouter())
gswag.SetTestServer(testServer)
})
var _ = SynchronizedAfterSuite(func() {
// Runs on every node — close server and write this node's partial spec.
testServer.Close()
Expect(gswag.WritePartialSpec(GinkgoParallelProcess(), "./tmp/gswag")).To(Succeed())
}, func() {
// Runs only on node 1, after all other nodes finish the block above.
suiteCfg, _ := GinkgoConfiguration()
Expect(gswag.MergeAndWriteSpec(suiteCfg.ParallelTotal, "./tmp/gswag")).To(Succeed())
})Partial files are written as ./tmp/gswag/node-N.json. MergeAndWriteSpec polls for each file with a configurable timeout (default 30 s, override via Config.MergeTimeout).
Every Ginkgo process builds the full spec tree during package init, so every partial spec contains all path skeletons. The per-node difference is runtime-inferred data:
- Response schemas — inferred from the live HTTP response only on the node that ran that test.
- Request body schemas — inferred from
SetBodyonly on the node that ran the POST/PUT/PATCH test.
MergeAndWriteSpec merges partial specs so that runtime-inferred content from any node fills in gaps in the base (node 1): missing response status codes are added, empty responses are replaced with schema-carrying ones from other nodes, and missing requestBody is copied over.
See examples/parallel for a working end-to-end demo using schema inference across parallel nodes.