Skip to content

oaswrap/gswag

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

30 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gswag

CI Go Reference Go Report Card codecov License: MIT

Generate OpenAPI 3.0 specs directly from your Ginkgo integration tests.

Inspired by rswag: define API docs alongside executable tests using a nested DSL.

How it works

gswag wraps Ginkgo containers (Path, Get/Post/..., Response, RunTest).

  1. During test tree construction, it records operation metadata (path, method, params, schemas, security).
  2. During test execution, RunTest makes a real HTTP request against your test server.
  3. Responses are asserted with Gomega and used to infer/capture examples when configured.
  4. WriteSpec() serializes the in-memory OpenAPI document.

Installation

go get github.com/oaswrap/gswag

Requires Go 1.24+.

Quick Start

1. Configure suite and server

// 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())
})

2. Write API specs with the DSL

// 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"))
            })
        })
    })
})

DSL Reference

Path and operations

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() { ... })
})

Operation metadata

Tag("users", "admin")
Description("Returns one user")
OperationID("getUser")
Deprecated()
Hidden() // run test, but do not add the operation to the spec

Security

BearerAuth()                    // uses "bearerAuth"
Security("apiKey")             // custom scheme
// Security("oauth2", "scope") // with scopes if needed

Parameters

Parameter("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))

Request and response schema

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)

Content types

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")
    ...
})

Shared request setup with BeforeRequest

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: BeforeRequest runs during test execution (Ginkgo BeforeEach), 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 the Response block.

Config

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)

Gomega Matchers

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"))
})

Validation

Validate in-memory spec

issues := ValidateSpec()
for _, issue := range issues {
    fmt.Println(issue.String())
}

Validate file

issues, err := ValidateSpecFile("docs/openapi.yaml")

Write then validate

if err := WriteAndValidateSpec(); err != nil {
    // err wraps ErrSpecInvalid when error-level issues exist
    panic(err)
}

Validation highlights:

  • info.title and info.version required (error)
  • empty paths (warning)
  • operation missing summary/tags (warning)
  • undeclared security scheme references (error)

Parallel Ginkgo Support

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).

How the merge works

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 SetBody only 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.

About

Generate OpenAPI 3.0 specs as a side-effect of running Ginkgo integration tests — no annotations required.

Topics

Resources

License

Contributing

Security policy

Stars

Watchers

Forks

Contributors