emicklei / go-restful-openapi

OpenAPI extension in Go for the go-restful package
MIT License
135 stars 72 forks source link

Response schema for []byte is always generated as array of integer rather than byte/binary string #115

Open brentwritescode opened 11 months ago

brentwritescode commented 11 months ago

Hello,

I'm currently running into an issue where the Swagger generated for our service definition translates Writes([]byte{}) to an array of integer in the output Swagger spec. This is causing some issues for downstream code generation where clients are incorrectly attempting to use []int rather than []byte when forming requests I saw another user mention the same thing in another issue here: https://github.com/emicklei/go-restful-openapi/issues/77#issuecomment-1005367776

To be very specific, going by https://swagger.io/docs/specification/data-models/data-types/#file, I need a way to make this translate to "type": "string" and "format": "binary" (or "byte") in the output Swagger definition. Is there a good way to do that?

I adapted one of your previous examples to show what I mean if it helps. I made a really simple API that just returns a random byte array:

package main

import (
    "crypto/rand"
    "log"
    "net/http"

    restful "github.com/emicklei/go-restful"

    restfulspec "github.com/emicklei/go-restful-openapi"
)

func getRandomBytes(size int) ([]byte, error) {
    b := make([]byte, size)
    _, err := rand.Read(b)
    if err != nil {
        return nil, err
    }
    return b, nil
}

func WebService() *restful.WebService {
    ws := new(restful.WebService)
    ws.Path("/").Produces(restful.MIME_OCTET)

    ws.Route(ws.GET("/bytes").To(getRandomBytesTest).
        Doc("get random bytes").
        Writes([]byte{}).
        Produces(restful.MIME_OCTET).
        Returns(200, "OK", []byte{}))

    return ws
}

func getRandomBytesTest(_ *restful.Request, response *restful.Response) {
    responseBytes, _ := getRandomBytes(256)
    _, _ = response.Write(responseBytes)
}

func main() {
    restful.DefaultContainer.Add(WebService())

    config := restfulspec.Config{
        WebServices: restful.RegisteredWebServices(),
        APIPath:     "/apidocs.json",
    }
    restful.DefaultContainer.Add(restfulspec.NewOpenAPIService(config))

    log.Printf("Get the API using http://localhost:8080/apidocs.json")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

When running that application and visiting http://localhost:8080/apidocs.json, it produces the following Swagger:

{
 "swagger": "2.0",
 "paths": {
  "/bytes": {
   "get": {
    "produces": [
     "application/octet-stream"
    ],
    "summary": "get random bytes",
    "operationId": "getRandomBytesTest",
    "responses": {
     "200": {
      "description": "OK",
      "schema": {
       "type": "array",
       "items": {
        "type": "integer"
       }
      }
     }
    }
   }
  }
 }
}

But what I need is to be able to produce this instead for the response:

"responses": {
     "200": {
      "description": "OK",
      "schema": {
       "type": "string",
       "format": "binary"
      }
     }
    }

Is that something that would be feasible to do with the library? And if so, would you be able to suggest how I might be able to achieve that? Thank you for your time and help!

emicklei commented 11 months ago

thank for reporting this and providing the example with expected output. I need to look back at the setup to see what is possible now or what can be done to achieve this.

emicklei commented 11 months ago

i am investigating whether adding prop.Format = "binary" on line 319 will fix this

emicklei commented 11 months ago

for fields of type []byte you can put the format in a tag:


type BA struct { ByteArray        []byte `format:"binary"` }
``
brentwritescode commented 11 months ago

Thanks for the details!

I was wondering if there was a struct tag-style way to do it. At the moment, I'm literally returning a []byte as the response (e.g. picture what Amazon S3's GetObject API would return in the HTTP body https://docs.aws.amazon.com/AmazonS3/latest/API/API_GetObject.html). I'll see if I can experiment with the format:"binary" style of doing it to see if it will generate what I want.

We did find a workaround using PostBuildSwaggerObjectHandler from the example demonstrated on https://github.com/emicklei/go-restful-openapi/issues/77#issuecomment-963870791, though that effectively means we're still generating "incorrect" Swagger and then going back and editing it afterward which isn't ideal, but seems to work at least.

brentwritescode commented 11 months ago

As a quick experiment, I tried the previous strategy of using a struct with tags for an embedded field to see what it looks like:

package main

import (
    "crypto/rand"
    "log"
    "net/http"

    restful "github.com/emicklei/go-restful"

    restfulspec "github.com/emicklei/go-restful-openapi"
)

type BinaryResponse struct {
    bytes []byte `format:"binary"`
}

func getRandomBytes(size int) ([]byte, error) {
    b := make([]byte, size)
    _, err := rand.Read(b)
    if err != nil {
        return nil, err
    }
    return b, nil
}

func WebService() *restful.WebService {
    ws := new(restful.WebService)
    ws.Path("/").Produces(restful.MIME_OCTET)

    ws.Route(ws.GET("/bytes").To(getRandomBytesTest).
        Doc("get random bytes").
        Writes(BinaryResponse{}).
        //Produces(restful.MIME_OCTET).
        //Returns(200, "OK", []byte{}))
        Returns(200, "OK", BinaryResponse{}))

    return ws
}

func getRandomBytesTest(_ *restful.Request, response *restful.Response) {
    responseBytes, _ := getRandomBytes(256)
    //_, _ = response.Write(responseBytes)
    byteResponse := BinaryResponse{bytes: responseBytes}
    response.WriteEntity(byteResponse)
}

func main() {
    restful.DefaultContainer.Add(WebService())

    config := restfulspec.Config{
        WebServices: restful.RegisteredWebServices(),
        APIPath:     "/apidocs.json",
    }
    restful.DefaultContainer.Add(restfulspec.NewOpenAPIService(config))

    log.Printf("Get the API using http://localhost:8080/apidocs.json")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

The full output Swagger then comes out to something like this:

{
 "swagger": "2.0",
 "paths": {
  "/bytes": {
   "get": {
    "produces": [
     "application/octet-stream"
    ],
    "summary": "get random bytes",
    "operationId": "getRandomBytesTest",
    "responses": {
     "200": {
      "description": "OK",
      "schema": {
       "$ref": "#/definitions/main.BinaryResponse"
      }
     }
    }
   }
  }
 },
 "definitions": {
  "main.BinaryResponse": {
   "required": [
    "bytes"
   ],
   "properties": {
    "bytes": {
     "type": "string",
     "format": "binary"
    }
   }
  }
 }
}

So it definitely does do the binary property specification as you said. It's not entirely clear to me that in Swagger this means the response should be expected to be raw bytes versus bytes wrapped in a JSON response (though I am not a Swagger expert by any means).

brentwritescode commented 11 months ago

As a third example, this is what we're doing right now to fix up the Swagger before publishing using the PostBuildSwaggerObjectHandler:

package main

import (
    "crypto/rand"
    "log"
    "net/http"

    "github.com/go-openapi/spec"

    restful "github.com/emicklei/go-restful"

    restfulspec "github.com/emicklei/go-restful-openapi"
)

const (
    testPath = "/bytes"
)

func getRandomBytes(size int) ([]byte, error) {
    b := make([]byte, size)
    _, err := rand.Read(b)
    if err != nil {
        return nil, err
    }
    return b, nil
}

func WebService() *restful.WebService {
    ws := new(restful.WebService)
    ws.Path("/").Produces(restful.MIME_OCTET)

    ws.Route(ws.GET(testPath).To(getRandomBytesTest).
        Doc("get random bytes").
        Writes([]byte{}).
        Produces(restful.MIME_OCTET).
        Returns(200, "OK", []byte{}))

    return ws
}

func getRandomBytesTest(_ *restful.Request, response *restful.Response) {
    responseBytes, _ := getRandomBytes(256)
    _, _ = response.Write(responseBytes)
}

func main() {
    restful.DefaultContainer.Add(WebService())

    config := restfulspec.Config{
        WebServices:                   restful.RegisteredWebServices(),
        APIPath:                       "/apidocs.json",
        PostBuildSwaggerObjectHandler: interceptSwagger,
    }
    restful.DefaultContainer.Add(restfulspec.NewOpenAPIService(config))

    log.Printf("Get the API using http://localhost:8080/apidocs.json")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func interceptSwagger(s *spec.Swagger) {
    if s.Paths != nil {
        bytesPath := s.Paths.Paths[testPath]
        getObjRes := bytesPath.Get.Responses
        if getObjRes != nil {
            okRes := getObjRes.StatusCodeResponses[http.StatusOK]
            newSchema := new(spec.Schema)
            newSchema.Type = []string{"string"}
            newSchema.Format = "binary"
            okRes.Schema = newSchema
            getObjRes.StatusCodeResponses[http.StatusOK] = okRes
        }
    }
}

And that does produce more of the minimal Swagger we're going for:

{
 "swagger": "2.0",
 "paths": {
  "/bytes": {
   "get": {
    "produces": [
     "application/octet-stream"
    ],
    "summary": "get random bytes",
    "operationId": "getRandomBytesTest",
    "responses": {
     "200": {
      "description": "OK",
      "schema": {
       "type": "string",
       "format": "binary"
      }
     }
    }
   }
  }
 }
}