Skip to content
5 changes: 3 additions & 2 deletions .mise.toml
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
[tools]
go = "1.24.3"
golangci-lint = "2.1.1"
gotestsum = "latest"

[tasks.setup-vscode-symlinks]
description = "Create VSCode symlinks for tools not automatically handled by mise-vscode"
run = [
"mkdir -p .vscode/mise-tools",
"ln -sf $(mise exec golangci-lint@2.1.1 -- which golangci-lint) .vscode/mise-tools/golangci-lint",
"ln -sf $(mise exec golangci-lint@2.5.0 -- which golangci-lint) .vscode/mise-tools/golangci-lint",
]

[hooks]
postinstall = [
"git submodule update --init --recursive",
"mise exec [email protected] -- go install github.com/golangci/golangci-lint/v2/cmd/[email protected]",
"mise run setup-vscode-symlinks",
"go install go.uber.org/nilaway/cmd/nilaway@8ad05f0",
]
38 changes: 29 additions & 9 deletions arazzo/arazzo.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
"github.com/speakeasy-api/openapi/arazzo/core"
"github.com/speakeasy-api/openapi/extensions"
"github.com/speakeasy-api/openapi/internal/interfaces"
"github.com/speakeasy-api/openapi/internal/utils"
"github.com/speakeasy-api/openapi/internal/version"
"github.com/speakeasy-api/openapi/jsonschema/oas3"
"github.com/speakeasy-api/openapi/marshaller"
"github.com/speakeasy-api/openapi/pointer"
Expand All @@ -20,12 +20,31 @@ import (

// Version is the version of the Arazzo Specification that this package conforms to.
const (
Version = "1.0.1"
VersionMajor = 1
VersionMinor = 0
VersionPatch = 1
Version = "1.0.1"
)

func MinimumSupportedVersion() version.Version {
v, err := version.ParseVersion("1.0.0")
if err != nil {
panic("failed to parse minimum supported Arazzo version: " + err.Error())
}
if v == nil {
panic("minimum supported Arazzo version is nil")
}
return *v
}

func MaximumSupportedVersion() version.Version {
v, err := version.ParseVersion(Version)
if err != nil {
panic("failed to parse maximum supported Arazzo version: " + err.Error())
}
if v == nil {
panic("maximum supported Arazzo version is nil")
}
return *v
}

// Arazzo is the root object for an Arazzo document.
type Arazzo struct {
marshaller.Model[core.Arazzo]
Expand Down Expand Up @@ -105,13 +124,14 @@ func (a *Arazzo) Validate(ctx context.Context, opts ...validation.Option) []erro
core := a.GetCore()
errs := []error{}

arazzoMajor, arazzoMinor, arazzoPatch, err := utils.ParseVersion(a.Arazzo)
arazzoVersion, err := version.ParseVersion(a.Arazzo)
if err != nil {
errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo field version is invalid %s: %s", a.Arazzo, err.Error()), core, core.Arazzo))
}

if arazzoMajor != VersionMajor || arazzoMinor != VersionMinor || arazzoPatch > VersionPatch {
errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo field version only %s and below is supported", Version), core, core.Arazzo))
if arazzoVersion != nil {
if arazzoVersion.GreaterThan(MaximumSupportedVersion()) {
errs = append(errs, validation.NewValueError(validation.NewValueValidationError("arazzo field version only Arazzo versions between %s and %s are supported", MinimumSupportedVersion(), MaximumSupportedVersion()), core, core.Arazzo))
}
}

errs = append(errs, a.Info.Validate(ctx, opts...)...)
Expand Down
2 changes: 1 addition & 1 deletion arazzo/arazzo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ sourceDescriptions:
underlyingError error
}{
{line: 1, column: 1, underlyingError: validation.NewMissingFieldError("arazzo field workflows is missing")},
{line: 1, column: 9, underlyingError: validation.NewValueValidationError("arazzo field version only 1.0.1 and below is supported")},
{line: 1, column: 9, underlyingError: validation.NewValueValidationError("arazzo field version only Arazzo versions between 1.0.0 and 1.0.1 are supported")},
{line: 4, column: 3, underlyingError: validation.NewMissingFieldError("info field version is missing")},
{line: 6, column: 5, underlyingError: validation.NewMissingFieldError("sourceDescription field url is missing")},
{line: 7, column: 11, underlyingError: validation.NewValueValidationError("sourceDescription field type must be one of [openapi, arazzo]")},
Expand Down
40 changes: 0 additions & 40 deletions internal/utils/versions.go

This file was deleted.

103 changes: 103 additions & 0 deletions internal/version/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package version

import (
"fmt"
"strconv"
"strings"
)

type Version struct {
Major int
Minor int
Patch int
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}
}
var _ fmt.Stringer = (*Version)(nil)

this just adds a compile time check that you implement the Stringer interface correctly with your String() method


func New(major, minor, patch int) *Version {
return &Version{
Major: major,
Minor: minor,
Patch: patch,
}
}

func (v Version) String() string {
return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
}

func (v Version) Equal(other Version) bool {
return v.Major == other.Major && v.Minor == other.Minor && v.Patch == other.Patch
}

func (v Version) GreaterThan(other Version) bool {
if v.Major > other.Major {
return true
} else if v.Major < other.Major {
return false
}

if v.Minor > other.Minor {
return true
} else if v.Minor < other.Minor {
return false
}

return v.Patch > other.Patch
}

func (v Version) LessThan(other Version) bool {
return !v.Equal(other) && !v.GreaterThan(other)
}

func ParseVersion(version string) (*Version, error) {
parts := strings.Split(version, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid version %s", version)
}

major, err := strconv.Atoi(parts[0])
if err != nil {
return nil, fmt.Errorf("invalid major version %s: %w", parts[0], err)
}
if major < 0 {
return nil, fmt.Errorf("invalid major version %s: cannot be negative", parts[0])
}

minor, err := strconv.Atoi(parts[1])
if err != nil {
return nil, fmt.Errorf("invalid minor version %s: %w", parts[1], err)
}
if minor < 0 {
return nil, fmt.Errorf("invalid minor version %s: cannot be negative", parts[1])
}

patch, err := strconv.Atoi(parts[2])
if err != nil {
return nil, fmt.Errorf("invalid patch version %s: %w", parts[2], err)
}
if patch < 0 {
return nil, fmt.Errorf("invalid patch version %s: cannot be negative", parts[2])
}

return New(major, minor, patch), nil
}

func IsVersionGreaterOrEqual(a, b string) (bool, error) {
versionA, err := ParseVersion(a)
if err != nil {
return false, fmt.Errorf("invalid version %s: %w", a, err)
}

versionB, err := ParseVersion(b)
if err != nil {
return false, fmt.Errorf("invalid version %s: %w", b, err)
}
return versionA.Equal(*versionB) || versionA.GreaterThan(*versionB), nil
}

func IsVersionLessThan(a, b string) (bool, error) {
greaterOrEqual, err := IsVersionGreaterOrEqual(a, b)
if err != nil {
return false, err
}
return !greaterOrEqual, nil
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package utils
package version

import (
"testing"
Expand Down Expand Up @@ -52,11 +52,11 @@ func Test_ParseVersion_Success(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
major, minor, patch, err := ParseVersion(tt.args.version)
version, err := ParseVersion(tt.args.version)
require.NoError(t, err)
assert.Equal(t, tt.expectedMajor, major)
assert.Equal(t, tt.expectedMinor, minor)
assert.Equal(t, tt.expectedPatch, patch)
assert.Equal(t, tt.expectedMajor, version.Major)
assert.Equal(t, tt.expectedMinor, version.Minor)
assert.Equal(t, tt.expectedPatch, version.Patch)
})
}
}
Expand Down Expand Up @@ -131,11 +131,9 @@ func Test_ParseVersion_Error(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
major, minor, patch, err := ParseVersion(tt.args.version)
version, err := ParseVersion(tt.args.version)
require.Error(t, err)
assert.Equal(t, 0, major)
assert.Equal(t, 0, minor)
assert.Equal(t, 0, patch)
assert.Nil(t, version)
})
}
}
15 changes: 15 additions & 0 deletions openapi/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,27 @@ func createBootstrapTags() []*Tag {
return []*Tag{
{
Name: "users",
Summary: pointer.From("Users"),
Description: pointer.From("User management operations"),
Kind: pointer.From("nav"),
ExternalDocs: &oas3.ExternalDocumentation{
Description: pointer.From("User API documentation"),
URL: "https://docs.example.com/users",
},
},
{
Name: "admin",
Summary: pointer.From("Admin"),
Description: pointer.From("Administrative operations"),
Parent: pointer.From("users"),
Kind: pointer.From("nav"),
},
{
Name: "beta-features",
Summary: pointer.From("Beta"),
Description: pointer.From("Experimental features"),
Kind: pointer.From("badge"),
},
}
}

Expand Down
78 changes: 76 additions & 2 deletions openapi/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ func TestBundle_EmptyDocument(t *testing.T) {

// Test with minimal document
doc := &openapi.OpenAPI{
OpenAPI: "3.1.0",
OpenAPI: openapi.Version,
Info: openapi.Info{
Title: "Empty API",
Version: "1.0.0",
Expand All @@ -122,7 +122,7 @@ func TestBundle_EmptyDocument(t *testing.T) {
require.NoError(t, err)

// Document should remain unchanged
assert.Equal(t, "3.1.0", doc.OpenAPI)
assert.Equal(t, openapi.Version, doc.OpenAPI)
assert.Equal(t, "Empty API", doc.Info.Title)
assert.Equal(t, "1.0.0", doc.Info.Version)

Expand Down Expand Up @@ -172,3 +172,77 @@ func TestBundle_SiblingDirectories_Success(t *testing.T) {
// Compare the actual output with expected output
assert.Equal(t, string(expectedBytes), string(actualYAML), "Bundled document should match expected output")
}

func TestBundle_AdditionalOperations_Success(t *testing.T) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if possible I would try and add the additionalOperations test in to the original test above and update the expected doc etc, to make sure everything is working together.

Generally also with tests like this its better to be asserting the exact shape of the document like we do in the test above rather than all the assertions you have below to catch where we might be introducing unintended changes etc, but that is just another reason to integrate it into the test above (which will then also mean we have the inline tests also testing it)

t.Parallel()

ctx := t.Context()

// Load the input document with additionalOperations
inputFile, err := os.Open("testdata/inline/additionaloperations_input.yaml")
require.NoError(t, err)
defer inputFile.Close()

inputDoc, validationErrs, err := openapi.Unmarshal(ctx, inputFile)
require.NoError(t, err)
require.Empty(t, validationErrs, "Input document should be valid")

// Configure bundling options
opts := openapi.BundleOptions{
ResolveOptions: openapi.ResolveOptions{
RootDocument: inputDoc,
TargetLocation: "testdata/inline/additionaloperations_input.yaml",
},
NamingStrategy: openapi.BundleNamingFilePath,
}

// Bundle all external references
err = openapi.Bundle(ctx, inputDoc, opts)
require.NoError(t, err)

// Marshal the bundled document to YAML
var buf bytes.Buffer
err = openapi.Marshal(ctx, inputDoc, &buf)
require.NoError(t, err)
actualYAML := buf.String()

// Verify that external references in additionalOperations were bundled
assert.Contains(t, actualYAML, "components:", "Components section should be created")
assert.Contains(t, actualYAML, "additionalOperations:", "additionalOperations should be preserved")

// Verify external schemas were bundled into components
assert.Contains(t, actualYAML, "ResourceMetadata:", "External ResourceMetadata schema should be bundled")
assert.Contains(t, actualYAML, "SyncConfig:", "External SyncConfig schema should be bundled")
assert.Contains(t, actualYAML, "BatchConfig:", "External BatchConfig schema should be bundled")

// Verify external parameters were bundled
assert.Contains(t, actualYAML, "DestinationParam:", "External DestinationParam should be bundled")
assert.Contains(t, actualYAML, "ConfirmationParam:", "External ConfirmationParam should be bundled")

// Verify external responses were bundled
assert.Contains(t, actualYAML, "CopyResponse:", "External CopyResponse should be bundled")
assert.Contains(t, actualYAML, "ValidationErrorResponse:", "External ValidationErrorResponse should be bundled")

// Verify external request bodies were bundled
assert.Contains(t, actualYAML, "CopyRequest:", "External CopyRequest should be bundled")

// Verify references in additionalOperations now point to components
assert.Contains(t, actualYAML, "$ref: \"#/components/parameters/DestinationParam\"", "COPY operation should reference bundled parameter")
assert.Contains(t, actualYAML, "$ref: \"#/components/requestBodies/CopyRequest\"", "COPY operation should reference bundled request body")
assert.Contains(t, actualYAML, "$ref: \"#/components/responses/CopyResponse\"", "COPY operation should reference bundled response")

// Verify references in PURGE operation
assert.Contains(t, actualYAML, "$ref: \"#/components/parameters/ConfirmationParam\"", "PURGE operation should reference bundled parameter")
assert.Contains(t, actualYAML, "$ref: \"#/components/responses/ValidationErrorResponse\"", "PURGE operation should reference bundled response")

// Verify references in SYNC operation
assert.Contains(t, actualYAML, "$ref: \"#/components/schemas/SyncConfig\"", "SYNC operation should reference bundled schema")
assert.Contains(t, actualYAML, "$ref: \"#/components/schemas/SyncResult\"", "SYNC operation should reference bundled schema")

// Verify references in BATCH operation
assert.Contains(t, actualYAML, "$ref: \"#/components/schemas/BatchConfig\"", "BATCH operation should reference bundled schema")
assert.Contains(t, actualYAML, "$ref: \"#/components/schemas/BatchResult\"", "BATCH operation should reference bundled schema")

// Verify no external file references remain in additionalOperations
assert.NotContains(t, actualYAML, "external_custom_operations.yaml#/", "No external file references should remain")
}
Loading