diff --git a/internal/testutils/utils.go b/internal/testutils/utils.go index 154d51b..c4c4000 100644 --- a/internal/testutils/utils.go +++ b/internal/testutils/utils.go @@ -13,15 +13,19 @@ import ( "strconv" "testing" + "github.com/speakeasy-api/openapi/yml" "github.com/stretchr/testify/assert" "gopkg.in/yaml.v3" ) // TODO use these more in tests func CreateStringYamlNode(value string, line, column int) *yaml.Node { + cfg := yml.GetDefaultConfig() + return &yaml.Node{ Value: value, Kind: yaml.ScalarNode, + Style: cfg.ValueStringStyle, Tag: "!!str", Line: line, Column: column, diff --git a/swagger/testdata/petstore.expected.openapi.yaml b/swagger/testdata/petstore.expected.openapi.yaml new file mode 100644 index 0000000..d2c12bb --- /dev/null +++ b/swagger/testdata/petstore.expected.openapi.yaml @@ -0,0 +1,706 @@ +openapi: 3.0.0 +info: + title: Swagger Petstore + version: 1.0.7 + description: 'This is a sample server Petstore server. You can find out more about Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For this sample, you can use the api key `special-key` to test the authorization filters.' + termsOfService: http://swagger.io/terms/ + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html +externalDocs: + description: Find out more about Swagger + url: http://swagger.io +tags: + - name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: http://swagger.io + - name: store + description: Access to Petstore orders + - name: user + description: Operations about user + externalDocs: + description: Find out more about our store + url: http://swagger.io +servers: + - url: https://petstore.swagger.io/v2 + - url: http://petstore.swagger.io/v2 +paths: + /pet: + post: + operationId: addPet + summary: Add a new pet to the store + description: "" + tags: + - pet + security: + - petstore_auth: + - write:pets + - read:pets + requestBody: + description: Pet object that needs to be added to the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + "405": + description: Invalid input + put: + operationId: updatePet + summary: Update an existing pet + description: "" + tags: + - pet + security: + - petstore_auth: + - write:pets + - read:pets + requestBody: + description: Pet object that needs to be added to the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + "400": + description: Invalid ID supplied + "404": + description: Pet not found + "405": + description: Validation exception + /pet/findByStatus: + get: + operationId: findPetsByStatus + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + tags: + - pet + security: + - petstore_auth: + - write:pets + - read:pets + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + style: form + explode: true + schema: + type: array + items: + type: string + enum: + - available + - pending + - sold + default: available + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + "400": + description: Invalid status value + /pet/findByTags: + get: + operationId: findPetsByTags + summary: Finds Pets by tags + description: Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing. + tags: + - pet + security: + - petstore_auth: + - write:pets + - read:pets + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + style: form + explode: true + schema: + type: array + items: + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + "400": + description: Invalid tag value + deprecated: true + /pet/{petId}: + get: + operationId: getPetById + summary: Find pet by ID + description: Returns a single pet + tags: + - pet + security: + - api_key: [] + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + "400": + description: Invalid ID supplied + "404": + description: Pet not found + post: + operationId: updatePetWithForm + summary: Updates a pet in the store with form data + description: "" + tags: + - pet + security: + - petstore_auth: + - write:pets + - read:pets + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + name: + type: string + description: Updated name of the pet + status: + type: string + description: Updated status of the pet + required: false + responses: + "405": + description: Invalid input + delete: + operationId: deletePet + summary: Deletes a pet + description: "" + tags: + - pet + security: + - petstore_auth: + - write:pets + - read:pets + parameters: + - name: api_key + in: header + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + "400": + description: Invalid ID supplied + "404": + description: Pet not found + /pet/{petId}/uploadImage: + post: + operationId: uploadFile + summary: uploads an image + description: "" + tags: + - pet + security: + - petstore_auth: + - write:pets + - read:pets + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + requestBody: + content: + multipart/form-data: + schema: + type: object + properties: + additionalMetadata: + type: string + description: Additional data to pass to server + file: + type: string + format: binary + description: file to upload + required: false + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + /store/inventory: + get: + operationId: getInventory + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + tags: + - store + security: + - api_key: [] + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + /store/order: + post: + operationId: placeOrder + summary: Place an order for a pet + description: "" + tags: + - store + requestBody: + description: order placed for purchasing the pet + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + required: true + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + application/xml: + schema: + $ref: '#/components/schemas/Order' + "400": + description: Invalid Order + /store/order/{orderId}: + get: + operationId: getOrderById + summary: Find purchase order by ID + description: For valid response try integer IDs with value >= 1 and <= 10. Other values will generated exceptions + tags: + - store + parameters: + - name: orderId + in: path + description: ID of pet that needs to be fetched + required: true + schema: + type: integer + maximum: 10 + minimum: 1 + format: int64 + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + application/xml: + schema: + $ref: '#/components/schemas/Order' + "400": + description: Invalid ID supplied + "404": + description: Order not found + delete: + operationId: deleteOrder + summary: Delete purchase order by ID + description: For valid response try integer IDs with positive integer value. Negative or non-integer values will generate API errors + tags: + - store + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: integer + minimum: 1 + format: int64 + responses: + "400": + description: Invalid ID supplied + "404": + description: Order not found + /user: + post: + operationId: createUser + summary: Create user + description: This can only be done by the logged in user. + tags: + - user + requestBody: + description: Created user object + content: + application/json: + schema: + $ref: '#/components/schemas/User' + required: true + responses: + default: + description: successful operation + /user/createWithArray: + post: + operationId: createUsersWithArrayInput + summary: Creates list of users with given input array + description: "" + tags: + - user + requestBody: + description: List of user object + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + required: true + responses: + default: + description: successful operation + /user/createWithList: + post: + operationId: createUsersWithListInput + summary: Creates list of users with given input array + description: "" + tags: + - user + requestBody: + description: List of user object + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + required: true + responses: + default: + description: successful operation + /user/login: + get: + operationId: loginUser + summary: Logs user into the system + description: "" + tags: + - user + parameters: + - name: username + in: query + description: The user name for login + required: true + schema: + type: string + - name: password + in: query + description: The password for login in clear text + required: true + schema: + type: string + responses: + "200": + description: successful operation + headers: + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + content: + application/json: + schema: + type: string + application/xml: + schema: + type: string + "400": + description: Invalid username/password supplied + /user/logout: + get: + operationId: logoutUser + summary: Logs out current logged in user session + description: "" + tags: + - user + responses: + default: + description: successful operation + /user/{username}: + get: + operationId: getUserByName + summary: Get user by user name + description: "" + tags: + - user + parameters: + - name: username + in: path + description: 'The name that needs to be fetched. Use user1 for testing. ' + required: true + schema: + type: string + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + "400": + description: Invalid username supplied + "404": + description: User not found + put: + operationId: updateUser + summary: Updated user + description: This can only be done by the logged in user. + tags: + - user + parameters: + - name: username + in: path + description: name that need to be updated + required: true + schema: + type: string + requestBody: + description: Updated user object + content: + application/json: + schema: + $ref: '#/components/schemas/User' + required: true + responses: + "400": + description: Invalid user supplied + "404": + description: User not found + delete: + operationId: deleteUser + summary: Delete user + description: This can only be done by the logged in user. + tags: + - user + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + "400": + description: Invalid username supplied + "404": + description: User not found +components: + schemas: + ApiResponse: + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + Category: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: Category + Pet: + type: object + properties: + id: + type: integer + format: int64 + category: + $ref: '#/components/schemas/Category' + name: + type: string + example: doggie + photoUrls: + type: array + items: + type: string + xml: + name: photoUrl + xml: + wrapped: true + tags: + type: array + items: + $ref: '#/components/schemas/Tag' + xml: + name: tag + xml: + wrapped: true + status: + type: string + enum: + - available + - pending + - sold + description: pet status in the store + required: + - name + - photoUrls + xml: + name: Pet + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: Tag + Order: + type: object + properties: + id: + type: integer + format: int64 + petId: + type: integer + format: int64 + quantity: + type: integer + format: int32 + shipDate: + type: string + format: date-time + status: + type: string + enum: + - placed + - approved + - delivered + description: Order Status + complete: + type: boolean + xml: + name: Order + User: + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + firstName: + type: string + lastName: + type: string + email: + type: string + password: + type: string + phone: + type: string + userStatus: + type: integer + format: int32 + description: User Status + xml: + name: User + securitySchemes: + api_key: + type: apiKey + name: api_key + in: header + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://petstore.swagger.io/oauth/authorize + scopes: + read:pets: read your pets + write:pets: modify pets in your account diff --git a/swagger/testdata/petstore.swagger.yaml b/swagger/testdata/petstore.swagger.yaml new file mode 100644 index 0000000..5366307 --- /dev/null +++ b/swagger/testdata/petstore.swagger.yaml @@ -0,0 +1,717 @@ +--- +swagger: '2.0' +info: + description: 'This is a sample server Petstore server. You can find out more about + Swagger at [http://swagger.io](http://swagger.io) or on [irc.freenode.net, #swagger](http://swagger.io/irc/). For + this sample, you can use the api key `special-key` to test the authorization filters.' + version: 1.0.7 + title: Swagger Petstore + termsOfService: http://swagger.io/terms/ + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html +host: petstore.swagger.io +basePath: "/v2" +tags: +- name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: http://swagger.io +- name: store + description: Access to Petstore orders +- name: user + description: Operations about user + externalDocs: + description: Find out more about our store + url: http://swagger.io +schemes: +- https +- http +paths: + "/pet/{petId}/uploadImage": + post: + tags: + - pet + summary: uploads an image + description: '' + operationId: uploadFile + consumes: + - multipart/form-data + produces: + - application/json + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + type: integer + format: int64 + - name: additionalMetadata + in: formData + description: Additional data to pass to server + required: false + type: string + - name: file + in: formData + description: file to upload + required: false + type: file + responses: + '200': + description: successful operation + schema: + "$ref": "#/definitions/ApiResponse" + security: + - petstore_auth: + - write:pets + - read:pets + "/pet": + post: + tags: + - pet + summary: Add a new pet to the store + description: '' + operationId: addPet + consumes: + - application/json + - application/xml + produces: + - application/json + - application/xml + parameters: + - in: body + name: body + description: Pet object that needs to be added to the store + required: true + schema: + "$ref": "#/definitions/Pet" + responses: + '405': + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + put: + tags: + - pet + summary: Update an existing pet + description: '' + operationId: updatePet + consumes: + - application/json + - application/xml + produces: + - application/json + - application/xml + parameters: + - in: body + name: body + description: Pet object that needs to be added to the store + required: true + schema: + "$ref": "#/definitions/Pet" + responses: + '400': + description: Invalid ID supplied + '404': + description: Pet not found + '405': + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets + "/pet/findByStatus": + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + produces: + - application/json + - application/xml + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: true + type: array + items: + type: string + enum: + - available + - pending + - sold + default: available + collectionFormat: multi + responses: + '200': + description: successful operation + schema: + type: array + items: + "$ref": "#/definitions/Pet" + '400': + description: Invalid status value + security: + - petstore_auth: + - write:pets + - read:pets + "/pet/findByTags": + get: + tags: + - pet + summary: Finds Pets by tags + description: Multiple tags can be provided with comma separated strings. Use + tag1, tag2, tag3 for testing. + operationId: findPetsByTags + produces: + - application/json + - application/xml + parameters: + - name: tags + in: query + description: Tags to filter by + required: true + type: array + items: + type: string + collectionFormat: multi + responses: + '200': + description: successful operation + schema: + type: array + items: + "$ref": "#/definitions/Pet" + '400': + description: Invalid tag value + security: + - petstore_auth: + - write:pets + - read:pets + deprecated: true + "/pet/{petId}": + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + produces: + - application/json + - application/xml + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + type: integer + format: int64 + responses: + '200': + description: successful operation + schema: + "$ref": "#/definitions/Pet" + '400': + description: Invalid ID supplied + '404': + description: Pet not found + security: + - api_key: [] + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: '' + operationId: updatePetWithForm + consumes: + - application/x-www-form-urlencoded + produces: + - application/json + - application/xml + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + type: integer + format: int64 + - name: name + in: formData + description: Updated name of the pet + required: false + type: string + - name: status + in: formData + description: Updated status of the pet + required: false + type: string + responses: + '405': + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + delete: + tags: + - pet + summary: Deletes a pet + description: '' + operationId: deletePet + produces: + - application/json + - application/xml + parameters: + - name: api_key + in: header + required: false + type: string + - name: petId + in: path + description: Pet id to delete + required: true + type: integer + format: int64 + responses: + '400': + description: Invalid ID supplied + '404': + description: Pet not found + security: + - petstore_auth: + - write:pets + - read:pets + "/store/inventory": + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + produces: + - application/json + parameters: [] + responses: + '200': + description: successful operation + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + "/store/order": + post: + tags: + - store + summary: Place an order for a pet + description: '' + operationId: placeOrder + consumes: + - application/json + produces: + - application/json + - application/xml + parameters: + - in: body + name: body + description: order placed for purchasing the pet + required: true + schema: + "$ref": "#/definitions/Order" + responses: + '200': + description: successful operation + schema: + "$ref": "#/definitions/Order" + '400': + description: Invalid Order + "/store/order/{orderId}": + get: + tags: + - store + summary: Find purchase order by ID + description: For valid response try integer IDs with value >= 1 and <= 10. Other + values will generated exceptions + operationId: getOrderById + produces: + - application/json + - application/xml + parameters: + - name: orderId + in: path + description: ID of pet that needs to be fetched + required: true + type: integer + maximum: 10 + minimum: 1 + format: int64 + responses: + '200': + description: successful operation + schema: + "$ref": "#/definitions/Order" + '400': + description: Invalid ID supplied + '404': + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by ID + description: For valid response try integer IDs with positive integer value. + Negative or non-integer values will generate API errors + operationId: deleteOrder + produces: + - application/json + - application/xml + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + type: integer + minimum: 1 + format: int64 + responses: + '400': + description: Invalid ID supplied + '404': + description: Order not found + "/user/createWithList": + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithListInput + consumes: + - application/json + produces: + - application/json + - application/xml + parameters: + - in: body + name: body + description: List of user object + required: true + schema: + type: array + items: + "$ref": "#/definitions/User" + responses: + default: + description: successful operation + "/user/{username}": + get: + tags: + - user + summary: Get user by user name + description: '' + operationId: getUserByName + produces: + - application/json + - application/xml + parameters: + - name: username + in: path + description: 'The name that needs to be fetched. Use user1 for testing. ' + required: true + type: string + responses: + '200': + description: successful operation + schema: + "$ref": "#/definitions/User" + '400': + description: Invalid username supplied + '404': + description: User not found + put: + tags: + - user + summary: Updated user + description: This can only be done by the logged in user. + operationId: updateUser + consumes: + - application/json + produces: + - application/json + - application/xml + parameters: + - name: username + in: path + description: name that need to be updated + required: true + type: string + - in: body + name: body + description: Updated user object + required: true + schema: + "$ref": "#/definitions/User" + responses: + '400': + description: Invalid user supplied + '404': + description: User not found + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + produces: + - application/json + - application/xml + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + type: string + responses: + '400': + description: Invalid username supplied + '404': + description: User not found + "/user/login": + get: + tags: + - user + summary: Logs user into the system + description: '' + operationId: loginUser + produces: + - application/json + - application/xml + parameters: + - name: username + in: query + description: The user name for login + required: true + type: string + - name: password + in: query + description: The password for login in clear text + required: true + type: string + responses: + '200': + description: successful operation + headers: + X-Expires-After: + type: string + format: date-time + description: date in UTC when token expires + X-Rate-Limit: + type: integer + format: int32 + description: calls per hour allowed by the user + schema: + type: string + '400': + description: Invalid username/password supplied + "/user/logout": + get: + tags: + - user + summary: Logs out current logged in user session + description: '' + operationId: logoutUser + produces: + - application/json + - application/xml + parameters: [] + responses: + default: + description: successful operation + "/user/createWithArray": + post: + tags: + - user + summary: Creates list of users with given input array + description: '' + operationId: createUsersWithArrayInput + consumes: + - application/json + produces: + - application/json + - application/xml + parameters: + - in: body + name: body + description: List of user object + required: true + schema: + type: array + items: + "$ref": "#/definitions/User" + responses: + default: + description: successful operation + "/user": + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + consumes: + - application/json + produces: + - application/json + - application/xml + parameters: + - in: body + name: body + description: Created user object + required: true + schema: + "$ref": "#/definitions/User" + responses: + default: + description: successful operation +securityDefinitions: + api_key: + type: apiKey + name: api_key + in: header + petstore_auth: + type: oauth2 + authorizationUrl: https://petstore.swagger.io/oauth/authorize + flow: implicit + scopes: + read:pets: read your pets + write:pets: modify pets in your account +definitions: + ApiResponse: + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + Category: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: Category + Pet: + type: object + required: + - name + - photoUrls + properties: + id: + type: integer + format: int64 + category: + "$ref": "#/definitions/Category" + name: + type: string + example: doggie + photoUrls: + type: array + xml: + wrapped: true + items: + type: string + xml: + name: photoUrl + tags: + type: array + xml: + wrapped: true + items: + xml: + name: tag + "$ref": "#/definitions/Tag" + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + xml: + name: Pet + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: Tag + Order: + type: object + properties: + id: + type: integer + format: int64 + petId: + type: integer + format: int64 + quantity: + type: integer + format: int32 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + enum: + - placed + - approved + - delivered + complete: + type: boolean + xml: + name: Order + User: + type: object + properties: + id: + type: integer + format: int64 + username: + type: string + firstName: + type: string + lastName: + type: string + email: + type: string + password: + type: string + phone: + type: string + userStatus: + type: integer + format: int32 + description: User Status + xml: + name: User +externalDocs: + description: Find out more about Swagger + url: http://swagger.io diff --git a/swagger/upgrade.go b/swagger/upgrade.go index d33f780..28a71d9 100644 --- a/swagger/upgrade.go +++ b/swagger/upgrade.go @@ -70,8 +70,7 @@ func Upgrade(ctx context.Context, src *Swagger) (*openapi.OpenAPI, error) { dst.Security = convertSecurityRequirements(src) // External docs (root) - // Swagger ExternalDocs type differs from OpenAPI's; skip explicit convert at root - // (Operation-level externalDocs handled similarly) + dst.ExternalDocs = convertExternalDocs(src.ExternalDocs) // Rewrite schema $refs from "#/definitions/" -> "#/components/schemas/" rewriteRefTargets(ctx, dst) @@ -135,6 +134,17 @@ func copyExtensions(src *extensions.Extensions) *extensions.Extensions { return dst } +func convertExternalDocs(src *ExternalDocumentation) *oas3.ExternalDocumentation { + if src == nil { + return nil + } + return &oas3.ExternalDocumentation{ + Description: src.Description, + URL: src.URL, + Extensions: copyExtensions(src.Extensions), + } +} + func convertTags(src []*Tag) []*openapi.Tag { if len(src) == 0 { return nil @@ -145,13 +155,10 @@ func convertTags(src []*Tag) []*openapi.Tag { continue } out = append(out, &openapi.Tag{ - Name: t.Name, - Description: t.Description, - ExternalDocs: func() *oas3.ExternalDocumentation { - // Swagger Tag has ExternalDocs type swagger.ExternalDocumentation; omit mapping for now - return nil - }(), - Extensions: copyExtensions(t.Extensions), + Name: t.Name, + Description: t.Description, + ExternalDocs: convertExternalDocs(t.ExternalDocs), + Extensions: copyExtensions(t.Extensions), }) } return out @@ -419,6 +426,21 @@ func convertOperation(root *Swagger, src *Operation) *openapi.Operation { dst.Tags = append([]string{}, src.Tags...) } + // Security requirements + if len(src.Security) > 0 { + dst.Security = make([]*openapi.SecurityRequirement, 0, len(src.Security)) + for _, req := range src.Security { + if req == nil { + continue + } + secReq := openapi.NewSecurityRequirement() + for k, v := range req.All() { + secReq.Set(k, v) + } + dst.Security = append(dst.Security, secReq) + } + } + // Determine consumes/produces for this operation consumes := src.Consumes if len(consumes) == 0 { @@ -530,7 +552,7 @@ func convertOperation(root *Swagger, src *Operation) *openapi.Operation { } // required list is optional; omitted for minimal conversion for _, fp := range formParams { - propSchema := schemaForSwaggerParamType(fp) + propSchema := schemaForSwaggerParamType(fp, true) obj.Properties.Set(fp.Name, propSchema) } @@ -565,29 +587,63 @@ func anyRequired(params []*Parameter) bool { return false } -func schemaForSwaggerParamType(p *Parameter) *oas3.JSONSchema[oas3.Referenceable] { +func schemaForSwaggerParamType(p *Parameter, copyDescription bool) *oas3.JSONSchema[oas3.Referenceable] { if p == nil { return nil } switch { case p.Type != nil && *p.Type == "array": items := &oas3.Schema{Type: oas3.NewTypeFromString(oas3.SchemaType("string"))} - if p.Items != nil && p.Items.Type != "" { - items.Type = oas3.NewTypeFromString(oas3.SchemaType(strings.ToLower(p.Items.Type))) + if p.Items != nil { + if p.Items.Type != "" { + items.Type = oas3.NewTypeFromString(oas3.SchemaType(strings.ToLower(p.Items.Type))) + } + // Preserve enum and default from items + if len(p.Items.Enum) > 0 { + items.Enum = make([]values.Value, len(p.Items.Enum)) + copy(items.Enum, p.Items.Enum) + } + if p.Items.Default != nil { + items.Default = p.Items.Default + } } return oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{ Type: oas3.NewTypeFromString(oas3.SchemaType("array")), Items: oas3.NewJSONSchemaFromSchema[oas3.Referenceable](items), }) case p.Type != nil && *p.Type == "file": - return oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{ + schema := &oas3.Schema{ Type: oas3.NewTypeFromString(oas3.SchemaType("string")), Format: pointer.From("binary"), - }) + } + if p.Description != nil && copyDescription { + schema.Description = p.Description + } + return oas3.NewJSONSchemaFromSchema[oas3.Referenceable](schema) case p.Type != nil && *p.Type != "": - return oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{ + schema := &oas3.Schema{ Type: oas3.NewTypeFromString(oas3.SchemaType(strings.ToLower(*p.Type))), - }) + } + if p.Description != nil && copyDescription { + schema.Description = p.Description + } + if p.Format != nil { + schema.Format = p.Format + } + if p.Minimum != nil { + schema.Minimum = p.Minimum + } + if p.Maximum != nil { + schema.Maximum = p.Maximum + } + if len(p.Enum) > 0 { + schema.Enum = make([]values.Value, len(p.Enum)) + copy(schema.Enum, p.Enum) + } + if p.Default != nil { + schema.Default = p.Default + } + return oas3.NewJSONSchemaFromSchema[oas3.Referenceable](schema) default: // Body parameter case should not call this; fall back to string return oas3.NewJSONSchemaFromSchema[oas3.Referenceable](&oas3.Schema{ @@ -625,7 +681,7 @@ func convertParameter(p *Parameter) *openapi.Parameter { // schema from type/format/items (non-body only) if p.In != ParameterInBody { - dst.Schema = schemaForSwaggerParamType(p) + dst.Schema = schemaForSwaggerParamType(p, false) // collectionFormat -> style/explode if p.CollectionFormat != nil { switch *p.CollectionFormat { @@ -858,7 +914,11 @@ func rewriteRefTargets(ctx context.Context, doc *openapi.OpenAPI) { ref := string(js.GetReference()) if strings.HasPrefix(ref, "#/definitions/") { newRef := references.Reference(strings.Replace(ref, "#/definitions/", "#/components/schemas/", 1)) - *js = *oas3.NewJSONSchemaFromReference(newRef) + // Update only the reference, preserving all other metadata (XML, etc.) + schema := js.GetSchema() + if schema != nil { + schema.Ref = pointer.From(newRef) + } } return nil }, diff --git a/swagger/upgrade_test.go b/swagger/upgrade_test.go index 7f82fbd..019e1e0 100644 --- a/swagger/upgrade_test.go +++ b/swagger/upgrade_test.go @@ -2,6 +2,7 @@ package swagger import ( "bytes" + "os" "strings" "testing" @@ -881,20 +882,20 @@ paths: actualYAML := buf.String() - expectedYAML := `openapi: "3.0.0" + expectedYAML := `openapi: 3.0.0 info: - title: "Produces Override" - version: "1.0.0" + title: Produces Override + version: 1.0.0 paths: /data: get: responses: "200": - description: "ok" + description: ok content: text/plain: schema: - type: "string" + type: string ` require.Equal(t, expectedYAML, actualYAML, "operation-level produces should override global produces in response content") @@ -942,10 +943,10 @@ paths: actualYAML := buf.String() - expectedYAML := `openapi: "3.0.0" + expectedYAML := `openapi: 3.0.0 info: - title: "Submit API" - version: "1.0.0" + title: Submit API + version: 1.0.0 paths: /submit: post: @@ -953,16 +954,16 @@ paths: content: application/x-www-form-urlencoded: schema: - type: "object" + type: object properties: a: - type: "string" + type: string b: - type: "integer" + type: integer required: true responses: "200": - description: "ok" + description: ok ` require.Equal(t, expectedYAML, actualYAML, "formData without file should map to x-www-form-urlencoded and aggregate fields in object schema") @@ -1504,3 +1505,40 @@ func TestUpgrade_PathLevelParameters_JSON_Success(t *testing.T) { require.Equal(t, expected, actual, "path-level non-body parameters should be preserved and mapped with schema") } + +func TestUpgrade_Petstore_YAML_Success(t *testing.T) { + t.Parallel() + ctx := t.Context() + + // Read the petstore swagger file + inputBytes, err := os.ReadFile("testdata/petstore.swagger.yaml") + require.NoError(t, err, "should read petstore swagger file") + + // Unmarshal Swagger 2.0 + swDoc, validationErrs, err := Unmarshal(ctx, bytes.NewReader(inputBytes)) + require.NoError(t, err, "unmarshal should succeed") + require.Empty(t, validationErrs, "swagger should be valid") + + // Upgrade to OpenAPI 3.0 + oaDoc, err := Upgrade(ctx, swDoc) + require.NoError(t, err, "upgrade should succeed") + require.NotNil(t, oaDoc, "openapi document should not be nil") + + // Preserve YAML format + cfg := swDoc.GetCore().GetConfig() + oaDoc.GetCore().SetConfig(cfg) + + // Marshal the upgraded document + var buf bytes.Buffer + err = marshaller.Marshal(ctx, oaDoc, &buf) + require.NoError(t, err, "marshal should succeed") + + actualYAML := buf.String() + + // Read the expected output + expectedBytes, err := os.ReadFile("testdata/petstore.expected.openapi.yaml") + require.NoError(t, err, "should read expected openapi file") + expectedYAML := string(expectedBytes) + + require.Equal(t, expectedYAML, actualYAML, "upgraded petstore should match expected OpenAPI output") +} diff --git a/yml/config.go b/yml/config.go index b587959..556dc2d 100644 --- a/yml/config.go +++ b/yml/config.go @@ -3,6 +3,7 @@ package yml import ( "bytes" "context" + "strconv" "gopkg.in/yaml.v3" ) @@ -58,6 +59,10 @@ var defaultConfig = &Config{ OutputFormat: OutputFormatYAML, } +func GetDefaultConfig() *Config { + return defaultConfig +} + func ContextWithConfig(ctx context.Context, config *Config) context.Context { if config == nil { return ctx @@ -182,40 +187,40 @@ func inspectData(data []byte) (OutputFormat, int, IndentationStyle) { } func getGlobalStringStyle(doc *yaml.Node, cfg *Config) { - foundMapKeyStyle := false - foundStringValueStyle := false + const minSamples = 3 + + keyStyles := make([]yaml.Style, 0, minSamples) + valueStyles := make([]yaml.Style, 0, minSamples) var navigate func(node *yaml.Node) navigate = func(node *yaml.Node) { + if len(keyStyles) >= minSamples && len(valueStyles) >= minSamples { + return + } + switch node.Kind { case yaml.DocumentNode: navigate(node.Content[0]) case yaml.SequenceNode: for _, n := range node.Content { navigate(n) - - if foundMapKeyStyle && foundStringValueStyle { - return - } } case yaml.MappingNode: for i, n := range node.Content { if i%2 == 0 { - if n.Kind == yaml.ScalarNode && n.Tag == "!!str" { - cfg.KeyStringStyle = n.Style - foundMapKeyStyle = true + if n.Kind == yaml.ScalarNode && n.Tag == "!!str" && len(keyStyles) < minSamples { + keyStyles = append(keyStyles, n.Style) } } else { navigate(n) - if foundMapKeyStyle && foundStringValueStyle { - return - } } } case yaml.ScalarNode: - if node.Tag == "!!str" { - cfg.ValueStringStyle = node.Style - foundStringValueStyle = true + if node.Tag == "!!str" && len(valueStyles) < minSamples { + // Exclude quoted numbers - they need quotes but don't represent typical string style + if !looksLikeNumber(node.Value) { + valueStyles = append(valueStyles, node.Style) + } } case yaml.AliasNode: navigate(node.Alias) @@ -225,4 +230,49 @@ func getGlobalStringStyle(doc *yaml.Node, cfg *Config) { } navigate(doc) + + // Choose the most common style for keys + if len(keyStyles) > 0 { + cfg.KeyStringStyle = mostCommonStyle(keyStyles) + } + + // Choose the most common style for values + if len(valueStyles) > 0 { + cfg.ValueStringStyle = mostCommonStyle(valueStyles) + } +} + +// looksLikeNumber returns true if the string value looks like a number +func looksLikeNumber(s string) bool { + if s == "" { + return false + } + + // Try parsing as float (covers int, float, scientific notation) + _, err := strconv.ParseFloat(s, 64) + return err == nil +} + +// mostCommonStyle returns the most frequently occurring style from the provided styles +func mostCommonStyle(styles []yaml.Style) yaml.Style { + if len(styles) == 0 { + return 0 + } + + counts := make(map[yaml.Style]int) + for _, style := range styles { + counts[style]++ + } + + // Find the style with the highest count + var maxCount int + var mostCommon yaml.Style + for style, count := range counts { + if count > maxCount { + maxCount = count + mostCommon = style + } + } + + return mostCommon } diff --git a/yml/config_test.go b/yml/config_test.go index 18afc71..43978fb 100644 --- a/yml/config_test.go +++ b/yml/config_test.go @@ -507,3 +507,258 @@ nested: }) } } + +func TestGetConfigFromDoc_WithMixedStringStyles_Success(t *testing.T) { + t.Parallel() + tests := []struct { + name string + data []byte + doc *yaml.Node + expectedKeyStyle yaml.Style + expectedValueStyle yaml.Style + }{ + { + name: "mostly double quoted values", + data: []byte(` + key1: "value1" + key2: "value2" + key3: 'value3' +`), + doc: &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{ + { + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + {Value: "key1", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "value1", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key2", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "value2", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key3", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "value3", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.SingleQuotedStyle}, + }, + }, + }, + }, + expectedKeyStyle: 0, + expectedValueStyle: yaml.DoubleQuotedStyle, + }, + { + name: "mostly single quoted values", + data: []byte(` + key1: 'value1' + key2: 'value2' + key3: "value3" +`), + doc: &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{ + { + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + {Value: "key1", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "value1", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.SingleQuotedStyle}, + {Value: "key2", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "value2", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.SingleQuotedStyle}, + {Value: "key3", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "value3", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + }, + }, + }, + }, + expectedKeyStyle: 0, + expectedValueStyle: yaml.SingleQuotedStyle, + }, + { + name: "numeric strings excluded from value style detection", + data: []byte(` + key1: "123" + key2: "456" + key3: "actual string" + key4: "another string" + key5: "third string" +`), + doc: &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{ + { + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + {Value: "key1", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "123", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key2", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "456", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key3", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "actual string", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key4", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "another string", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key5", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "third string", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + }, + }, + }, + }, + expectedKeyStyle: 0, + expectedValueStyle: yaml.DoubleQuotedStyle, + }, + { + name: "mixed key styles chooses most common", + data: []byte(` + "key1": value1 + "key2": value2 + key3: value3 +`), + doc: &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{ + { + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + {Value: "key1", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "value1", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "key2", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "value2", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "key3", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + {Value: "value3", Kind: yaml.ScalarNode, Tag: "!!str", Style: 0}, + }, + }, + }, + }, + expectedKeyStyle: yaml.DoubleQuotedStyle, + expectedValueStyle: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := yml.GetConfigFromDoc(tt.data, tt.doc) + + require.NotNil(t, result) + assert.Equal(t, tt.expectedKeyStyle, result.KeyStringStyle, "should select most common key string style") + assert.Equal(t, tt.expectedValueStyle, result.ValueStringStyle, "should select most common value string style, excluding numbers") + }) + } +} + +func TestGetConfigFromDoc_WithNumericStrings_Success(t *testing.T) { + t.Parallel() + tests := []struct { + name string + data []byte + doc *yaml.Node + expectedValueStyle yaml.Style + description string + }{ + { + name: "integers as strings", + data: []byte(` + key1: "123" + key2: "456" + key3: "789" + key4: "real string" +`), + doc: &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{ + { + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + {Value: "key1", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "123", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key2", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "456", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key3", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "789", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key4", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "real string", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + }, + }, + }, + }, + expectedValueStyle: yaml.DoubleQuotedStyle, + description: "should ignore numeric strings and use style from actual strings", + }, + { + name: "floats as strings", + data: []byte(` + key1: "3.14" + key2: "2.71" + key3: "text value" + key4: "another text" + key5: "third text" +`), + doc: &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{ + { + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + {Value: "key1", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "3.14", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key2", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "2.71", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key3", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "text value", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key4", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "another text", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key5", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "third text", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + }, + }, + }, + }, + expectedValueStyle: yaml.DoubleQuotedStyle, + description: "should ignore float strings and use style from actual strings", + }, + { + name: "scientific notation as strings", + data: []byte(` + key1: "1e10" + key2: "2.5e-3" + key3: "regular text" + key4: "more text" + key5: "even more text" +`), + doc: &yaml.Node{ + Kind: yaml.DocumentNode, + Content: []*yaml.Node{ + { + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{ + {Value: "key1", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "1e10", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key2", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "2.5e-3", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key3", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "regular text", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key4", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "more text", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + {Value: "key5", Kind: yaml.ScalarNode, Tag: "!!str"}, + {Value: "even more text", Kind: yaml.ScalarNode, Tag: "!!str", Style: yaml.DoubleQuotedStyle}, + }, + }, + }, + }, + expectedValueStyle: yaml.DoubleQuotedStyle, + description: "should ignore scientific notation strings and use style from actual strings", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := yml.GetConfigFromDoc(tt.data, tt.doc) + + require.NotNil(t, result) + assert.Equal(t, tt.expectedValueStyle, result.ValueStringStyle, tt.description) + }) + } +}