Skip to content

Commit

Permalink
feat: implement mock oas command
Browse files Browse the repository at this point in the history
This command allows you to generate a mock API proxy from an OpenAPI 3.x spec.

The generated mock API proxy supports the following features:

  * Request validation (`Mock-Validate-Request` header)
  * Specific & Dynamic response status (`Mock-Status` header)
  * Specific & Dynamic response content-type (`Accept` header)
  * Specific & Dynamic response examples (`Mock-Fuzz`)
  * Random generator seeding (`Mock-Seed` header)
  • Loading branch information
micovery committed Oct 30, 2024
1 parent a6b6133 commit 2f9a6eb
Show file tree
Hide file tree
Showing 16 changed files with 2,276 additions and 93 deletions.
2 changes: 2 additions & 0 deletions cmd/apigee-go-gen/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package main

import (
"github.com/apigee/apigee-go-gen/cmd/apigee-go-gen/mock"
"github.com/apigee/apigee-go-gen/cmd/apigee-go-gen/render"
"github.com/apigee/apigee-go-gen/cmd/apigee-go-gen/transform"
"github.com/apigee/apigee-go-gen/pkg/flags"
Expand All @@ -35,6 +36,7 @@ func init() {

RootCmd.AddCommand(render.Cmd)
RootCmd.AddCommand(transform.Cmd)
RootCmd.AddCommand(mock.Cmd)
RootCmd.AddCommand(VersionCmd)

RootCmd.PersistentFlags().Var(&showStack, "show-stack", "show stack trace for errors")
Expand Down
29 changes: 29 additions & 0 deletions cmd/apigee-go-gen/mock/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright 2024 Google LLC
//
// 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 mock

import (
"github.com/apigee/apigee-go-gen/cmd/apigee-go-gen/mock/oas"
"github.com/spf13/cobra"
)

var Cmd = &cobra.Command{
Use: "mock",
Short: "Generate a mock API proxy",
}

func init() {
Cmd.AddCommand(oas.Cmd)
}
62 changes: 62 additions & 0 deletions cmd/apigee-go-gen/mock/oas/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright 2024 Google LLC
//
// 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 oas

import (
"fmt"
"github.com/apigee/apigee-go-gen/pkg/common/resources"
"github.com/apigee/apigee-go-gen/pkg/flags"
"github.com/apigee/apigee-go-gen/pkg/mock"
"github.com/spf13/cobra"
)

var input = flags.NewString("")
var output = flags.NewString("")

var Cmd = &cobra.Command{
Use: "oas",
Short: "Generate a mock API proxy from an OpenAPI 3.X spec",
Long: Usage(),
RunE: func(cmd *cobra.Command, args []string) error {
return mock.GenerateMockProxyBundle(string(input), string(output))
},
}

func init() {
Cmd.Flags().SortFlags = false
Cmd.Flags().VarP(&input, "input", "i", `path to input spec (e.g. ./path/to/spec.yaml"`)
Cmd.Flags().VarP(&output, "output", "o", `OpenAPI spec to use"`)

_ = Cmd.MarkFlagRequired("input")
_ = Cmd.MarkFlagRequired("output")
}

func Usage() string {
usageText := `
This command generates a mock API proxy bundle from an OpenAPI 3.X Spec.
The mock API proxy includes the following features:
%[1]s
`

mockFeatures, err := resources.FS.ReadFile("mock_features.txt")
if err != nil {
panic(err)
}

return fmt.Sprintf(usageText, mockFeatures)
}
165 changes: 165 additions & 0 deletions docs/mock/commands/mock-oas.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# Mock OAS
<!--
Copyright 2024 Google LLC
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.
-->

This command generates a mock Apigee API proxy from your OpenAPI 3.X specification.

This mock API proxy dynamically creates responses based on the information within your spec, allowing for quick and easy API mocking.

## Usage

The `mock oas` command takes the following parameters:

* `--input string`

* This is the path to the OpenAPI spec file to use (either YAML or JSON)

* You can use `--input -` to read from standard input (stdin)

* Internally, the OpenAPI spec is converted to JSON when generating the mock API proxy.


* `--output string`

* This is the location where the mock API proxy bundle will be created.


The tool can create the mock API proxy either as a directory or as a zip. e.g.

* If you provide a path like this `--output=./path/to/apiproxy.zip`, then a proxy bundle zip is created.
* If you provide a path like this `--output=./path/to/apiproxy`, then a proxy bundle directory is created


## Mock API Proxy Features

The generated mock API proxy supports the following features listed below.


### Request Validation

By default, the mock API proxy validates the incoming requests against your OpenAPI specification.
This ensures that the headers, query parameters, and request body all conform to the defined rules.

This helps you catch errors in your client code early on.

You can disable request validation by passing the header `Mock-Validate-Request: false`.

### Dynamic Response Status Codes

The mock API proxy automatically generates different status codes for your mock API responses. Here's how it works:

* **Prioritizes success:** If the operation allows HTTP 200 status code, the proxy will use it.
* **Random selection:** If HTTP 200 is not allowed for a particular operation, the proxy will pick a random status code from those allowed.

**Want more control?** You can use headers to select response the status code:

* **Specific status code:** Use the `Mock-Status` header in your request and set it to the desired code (e.g., `Mock-Status: 404`).
* **Random status code:** Use the `Mock-Fuzz: true` header to get a random status code from your spec.

If you use both `Mock-Status` and `Mock-Fuzz`, `Mock-Status` takes precedence.

### Content-Type Negotiation

The mock API proxy automatically selects the `Content-Type` for responses:

* **JSON preferred:** If the operation allows `application/json`, the proxy will default to using it.
* **Random selection:** If `application/json` is not available, the proxy will randomly choose from the other media types available for that operation.

**Want more control?** You can use headers to select the response Content-Type:

* **Standard `Accept` header:** You can use the standard `Accept` header in your request to request a specific media type (e.g., `Accept: application/xml`).
* **Random media type:** Alternatively, use the `Mock-Fuzz: true` header to have the proxy select a random media type the available ones.

If you use both `Accept` and `Mock-Fuzz`, the `Accept` header will take precedence.


### Dynamic Response Bodies

The mock API proxy can generate realistic response bodies based on your OpenAPI spec.

Here's how it determines what to send back for any particular operation's response (in order):

1. **Prioritizes `example` field:** If the response includes an `example` field, the proxy will use that example.

2. **Handles multiple `examples`:** If the response has an `examples` field with multiple examples, the proxy will randomly select one. You can use the `Mock-Example` header to specify which example you want (e.g., `Mock-Example: my-example`).

3. **Uses schema examples:** If no response examples are provided, but the schema for the response has an `example`, the proxy will use that.

4. **Generates from schema:** As a last resort, the proxy will generate a random example based on the response schema. This works for JSON, YAML, and XML.

You can use the `Mock-Fuzz: true` header to force the proxy to always generate a random example from the schema, even if other examples are available.

### Making Mock API Responses Repeatable

The mock API proxy uses a special technique to make its responses seem random, while still allowing you to get the same response again if needed. Here's how it works:

* **Pseudo-random numbers:** The "random" choices the proxy makes (like status codes and content) are actually generated using a pseudo-random number generator (PRNG). This means the responses look random, but are determined by a starting value called a "seed."

* **Unique seeds:** Each request uses a different seed, so responses vary. However, the seed is provided in a special response header called `Mock-Seed`.

* **Getting the same response:** To get an identical response, simply include the `Mock-Seed` header in a new request, using the value from a previous response. This forces the proxy to use the same seed and generate the same "random" choices, resulting in an identical response.

This feature is super helpful for:

* **Testing:** Ensuring your tests always get the same response.
* **Debugging:** Easily recreating specific scenarios to pinpoint issues in application code.

Essentially, by using the `Mock-Seed` header, you can control the randomness of the mock API responses, making them repeatable for testing and debugging.

### Example Generation from JSON Schemas

The following fields are supported when generating examples from a JSON schema:

* `$ref` - local references are followed
* `$oneOf` - chooses a random schema
* `$anyOf` - chooses a random schema
* `$allOf` - combines all schemas
* `obect` type
* `required` field - all required properties are chosen
* `properties` field - a random set of properties is chosen
* `additionalProperties` field - only used when there are no `properties` defined
* `array` type
* `minItems`, `maxItems` fields - array length chosen randomly between these values
* `items` field - determines the type of array elements
* `prefixItems` (not supported yet)
* `null` type
* `const` type
* `boolean` type - true or false randomly chosen
* `string` type
* `enum` field - a random value is chosen
* `pattern` field (not supported yet)
* `format` field
* `date-time` format
* `date` format
* `time` format
* `email` format
* `uuid` format
* `uri` format
* `hostname` format
* `ipv4` format
* `ipv6` format
* `duration` format
* `minLength`, `maxLength` fields - string length chosen randomly between these values
* `integer` type
* `minimum`, `maximum` fields - integer value chosen randomly between these values
* `exclusiveMinimuim` field (boolean, JSON-Schema 4)
* `exclusiveMaximum` field (boolean, JSON-Schema 4)
* `multipleOf` field
* `number` type
* `minimum`, `maximum` fields - integer value chosen randomly between these values
* `exclusiveMinimuim` field (boolean, JSON-Schema 4)
* `exclusiveMaximum` field (boolean, JSON-Schema 4)
* `multipleOf` field
92 changes: 1 addition & 91 deletions pkg/apiproxy/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,13 @@
package apiproxy

import (
"archive/zip"
"fmt"
v1 "github.com/apigee/apigee-go-gen/pkg/apigee/v1"
"github.com/apigee/apigee-go-gen/pkg/render"
"github.com/apigee/apigee-go-gen/pkg/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"io"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
)

Expand Down Expand Up @@ -98,91 +92,7 @@ func TestAPIProxyModel2BundleZip(t *testing.T) {
err = render.CreateBundle(model, outputBundleZipPath, false, "")
require.NoError(t, err)

RequireBundleZipEquals(t, expectedBundleZipPath, outputBundleZipPath)
utils.RequireBundleZipEquals(t, expectedBundleZipPath, outputBundleZipPath)
})
}
}

func RequireBundleZipEquals(t *testing.T, expectedBundleZip string, actualBundleZip string) {
expectedReader, err := zip.OpenReader(expectedBundleZip)
require.NoError(t, err)
defer MustClose(expectedReader)

actualReader, err := zip.OpenReader(actualBundleZip)
require.NoError(t, err)
defer MustClose(actualReader)

getFilesSorted := func(reader *zip.ReadCloser) []*zip.File {
zipFiles := []*zip.File{}
for _, f := range reader.File {
if f.FileInfo().IsDir() {
continue
}
zipFiles = append(zipFiles, f)
}

slices.SortFunc(zipFiles, func(a, b *zip.File) int {
return strings.Compare(a.Name, b.Name)
})

return zipFiles
}

expectedFiles := getFilesSorted(expectedReader)
actualFiles := getFilesSorted(actualReader)

getFileNames := func(files []*zip.File) []string {
result := []string{}
for _, file := range files {
result = append(result, file.Name)
}

return result
}

expectedFileNames := getFileNames(expectedFiles)
actualFileNames := getFileNames(actualFiles)

require.Equal(t, expectedFileNames, actualFileNames, "API proxy structures do not match")
for index, expectedFile := range expectedFiles {
actualFile := actualFiles[index]

expectedFileReader, err := expectedFile.Open()
require.NoError(t, err)

actualFileReader, err := actualFile.Open()
require.NoError(t, err)

extension := filepath.Ext(actualFile.Name)
if extension == ".xml" {
expected, err := utils.XMLText2YAMLText(expectedFileReader)
require.NoError(t, err)

expected = RemoveYAMLComments(expected)
actual, err := utils.XMLText2YAMLText(actualFileReader)
require.NoError(t, err)

require.YAMLEq(t, string(expected), string(actual), fmt.Sprintf("%s XML contents do not match", expectedFile.Name))
} else {
expectedContents, err := io.ReadAll(expectedFileReader)
require.NoError(t, err)

expectedContents = RemoveYAMLComments(expectedContents)
actualContents, err := io.ReadAll(actualFileReader)
require.Equal(t, string(expectedContents), string(actualContents), fmt.Sprintf("%s contents do not match", expectedFile.Name))
}
}
}

func MustClose(reader *zip.ReadCloser) {
err := reader.Close()
if err != nil {
panic(err)
}
}

func RemoveYAMLComments(data []byte) []byte {
regex := regexp.MustCompile(`(?ms)^\s*#[^\n\r]*$[\r\n]*`)
replaced := regex.ReplaceAll(data, []byte{})
return replaced
}
Loading

0 comments on commit 2f9a6eb

Please sign in to comment.