diff --git a/.changeset/smooth-apes-smell.md b/.changeset/smooth-apes-smell.md new file mode 100644 index 000000000..1d8481c95 --- /dev/null +++ b/.changeset/smooth-apes-smell.md @@ -0,0 +1,5 @@ +--- +"gitbook": patch +--- + +Update OpenAPI parser diff --git a/.changeset/two-apples-decide.md b/.changeset/two-apples-decide.md new file mode 100644 index 000000000..344113ff0 --- /dev/null +++ b/.changeset/two-apples-decide.md @@ -0,0 +1,5 @@ +--- +"@gitbook/openapi-parser": major +--- + +Less strict OpenAPI parser, now returns { errors, filesystem } diff --git a/packages/gitbook/src/lib/openapi/enrich.test.ts b/packages/gitbook/src/lib/openapi/enrich.test.ts index 7c6bcdafa..6e91b1a83 100644 --- a/packages/gitbook/src/lib/openapi/enrich.test.ts +++ b/packages/gitbook/src/lib/openapi/enrich.test.ts @@ -7,7 +7,7 @@ const spec = await Bun.file(new URL('./fixtures/multiline-spec.yaml', import.met describe('#enrichFilesystem', () => { it('supports multiline descriptions', async () => { - const filesystem = await parseOpenAPI({ + const { filesystem } = await parseOpenAPI({ value: spec, rootURL: null, }); diff --git a/packages/gitbook/src/lib/openapi/fetch.ts b/packages/gitbook/src/lib/openapi/fetch.ts index af9bba3eb..a5dabcb5b 100644 --- a/packages/gitbook/src/lib/openapi/fetch.ts +++ b/packages/gitbook/src/lib/openapi/fetch.ts @@ -108,13 +108,7 @@ async function fetchFilesystemUncached( } const text = await response.text(); - const filesystem = await parseOpenAPI({ - value: text, - rootURL: url, - // If we fetch the OpenAPI specification - // it's the legacy system, it means the spec can be trusted here. - trust: true, - }); + const { filesystem } = await parseOpenAPI({ value: text, rootURL: url }); const richFilesystem = await enrichFilesystem(filesystem); return richFilesystem; diff --git a/packages/openapi-parser/src/fixtures/petstore-invalid.json b/packages/openapi-parser/src/fixtures/petstore-invalid.json new file mode 100644 index 000000000..1f1b35962 --- /dev/null +++ b/packages/openapi-parser/src/fixtures/petstore-invalid.json @@ -0,0 +1,1270 @@ +{ + "openapi": "3.0.4", + "info": { + "title": "Swagger Petstore - OpenAPI 3.0", + "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)", + "termsOfService": "https://swagger.io/terms/", + "contact": { + "email": "apiteam@swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + }, + "version": "1.0.26" + }, + "externalDocs": { + "description": "Find out more about Swagger", + "url": "https://swagger.io" + }, + "servers": [ + { + "url": "/api/v3" + } + ], + "tags": [ + { + "name": "pet", + "description": "Everything about your Pets", + "externalDocs": { + "description": "Find out more", + "url": "https://swagger.io" + } + }, + { + "name": "store", + "descriptionx": "Access to Petstore orders", + "externalDocs": { + "description": "Find out more about our store", + "url": "https://swagger.io" + } + }, + { + "name": "user", + "description": "Operations about user" + } + ], + "paths": { + "/pet": { + "put": { + "tags": [ + "pet" + ], + "summary": "Update an existing pet.", + "description": "Update an existing pet by Id.", + "operationId": "updatePet", + "requestBody": { + "description": "Update an existent pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "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" + }, + "422": { + "description": "Validation exception" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Add a new pet to the store.", + "description": "Add a new pet to the store.", + "operationId": "addPet", + "requestBody": { + "description": "Create a new pet in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "422": { + "description": "Validation exception" + }, + "default": { + "description": "Unexpected error" + } + }, + "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", + "parameters": [ + { + "name": "status", + "in": "query", + "description": "Status values that need to be considered for filter", + "required": false, + "explode": true, + "schema": { + "type": "string", + "default": "available", + "enum": [ + "available", + "pending", + "sold" + ] + } + } + ], + "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" + }, + "default": { + "description": "Unexpected error" + } + }, + "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", + "parameters": [ + { + "name": "tags", + "in": "query", + "description": "Tags to filter by", + "required": false, + "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" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}": { + "get": { + "tags": [ + "pet" + ], + "summary": "Find pet by ID.", + "description": "Returns a single pet.", + "operationId": "getPetById", + "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" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "api_key": [] + }, + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "post": { + "tags": [ + "pet" + ], + "summary": "Updates a pet in the store with form data.", + "description": "Updates a pet resource based on the form data.", + "operationId": "updatePetWithForm", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet that needs to be updated", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "name", + "in": "query", + "description": "Name of pet that needs to be updated", + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "Status of pet that needs to be updated", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + }, + "delete": { + "tags": [ + "pet" + ], + "summary": "Deletes a pet.", + "description": "Delete a pet.", + "operationId": "deletePet", + "parameters": [ + { + "name": "api_key", + "in": "header", + "description": "", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "petId", + "in": "path", + "description": "Pet id to delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Pet deleted" + }, + "400": { + "description": "Invalid pet value" + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "petstore_auth": [ + "write:pets", + "read:pets" + ] + } + ] + } + }, + "/pet/{petId}/uploadImage": { + "post": { + "tags": [ + "pet" + ], + "summary": "Uploads an image.", + "description": "Upload image of the pet.", + "operationId": "uploadFile", + "parameters": [ + { + "name": "petId", + "in": "path", + "description": "ID of pet to update", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "additionalMetadata", + "in": "query", + "description": "Additional Metadata", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiResponse" + } + } + } + }, + "400": { + "description": "No file uploaded" + }, + "404": { + "description": "Pet not found" + }, + "default": { + "description": "Unexpected error" + } + }, + "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", + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "default": { + "description": "Unexpected error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, + "/store/order": { + "post": { + "tags": [ + "store" + ], + "summary": "Place an order for a pet.", + "description": "Place a new order in the store.", + "operationId": "placeOrder", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/Order" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "400": { + "description": "Invalid input" + }, + "422": { + "description": "Validation exception" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/store/order/{orderId}": { + "get": { + "tags": [ + "store" + ], + "summary": "Find purchase order by ID.", + "description": "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.", + "operationId": "getOrderById", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of order that needs to be fetched", + "required": true, + "schema": { + "type": "integer", + "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" + }, + "default": { + "description": "Unexpected error" + } + } + }, + "delete": { + "tags": [ + "store" + ], + "summary": "Delete purchase order by identifier.", + "description": "For valid response try integer IDs with value < 1000. Anything above 1000 or non-integers will generate API errors.", + "operationId": "deleteOrder", + "parameters": [ + { + "name": "orderId", + "in": "path", + "description": "ID of the order that needs to be deleted", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "order deleted" + }, + "400": { + "description": "Invalid ID supplied" + }, + "404": { + "description": "Order not found" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user": { + "post": { + "tags": [ + "user" + ], + "summary": "Create user.", + "description": "This can only be done by the logged in user.", + "operationId": "createUser", + "requestBody": { + "description": "Created user object", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user/createWithList": { + "post": { + "tags": [ + "user" + ], + "summary": "Creates list of users with given input array.", + "description": "Creates list of users with given input array.", + "operationId": "createUsersWithListInput", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user/login": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs user into the system.", + "description": "Log into the system.", + "operationId": "loginUser", + "parameters": [ + { + "name": "username", + "in": "query", + "description": "The user name for login", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "password", + "in": "query", + "description": "The password for login in clear text", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "headers": { + "X-Rate-Limit": { + "description": "calls per hour allowed by the user", + "schema": { + "type": "integer", + "format": "int32" + } + }, + "X-Expires-After": { + "description": "date in UTC when token expires", + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/xml": { + "schema": { + "type": "string" + } + }, + "application/json": { + "schema": { + "type": "string" + } + } + } + }, + "400": { + "description": "Invalid username/password supplied" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user/logout": { + "get": { + "tags": [ + "user" + ], + "summary": "Logs out current logged in user session.", + "description": "Log user out of the system.", + "operationId": "logoutUser", + "parameters": [], + "responses": { + "200": { + "description": "successful operation" + }, + "default": { + "description": "Unexpected error" + } + } + } + }, + "/user/{username}": { + "get": { + "tags": [ + "user" + ], + "summary": "Get user by user name.", + "description": "Get user detail based on username.", + "operationId": "getUserByName", + "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" + }, + "default": { + "description": "Unexpected error" + } + } + }, + "put": { + "tags": [ + "user" + ], + "summary": "Update user resource.", + "description": "This can only be done by the logged in user.", + "operationId": "updateUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "name that need to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Update an existent user in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/User" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "responses": { + "200": { + "description": "successful operation" + }, + "400": { + "description": "bad request" + }, + "404": { + "description": "user not found" + }, + "default": { + "description": "Unexpected error" + } + } + }, + "delete": { + "tags": [ + "user" + ], + "summary": "Delete user resource.", + "description": "This can only be done by the logged in user.", + "operationId": "deleteUser", + "parameters": [ + { + "name": "username", + "in": "path", + "description": "The name that needs to be deleted", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "User deleted" + }, + "400": { + "description": "Invalid username supplied" + }, + "404": { + "description": "User not found" + }, + "default": { + "description": "Unexpected error" + } + } + } + } + }, + "components": { + "schemas": { + "Order": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "petId": { + "type": "integer", + "format": "int64", + "example": 198772 + }, + "quantity": { + "type": "integer", + "format": "int32", + "example": 7 + }, + "shipDate": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "description": "Order Status", + "example": "approved", + "enum": [ + "placed", + "approved", + "delivered" + ] + }, + "complete": { + "type": "boolean" + } + }, + "xml": { + "name": "order" + } + }, + "Category": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 1 + }, + "name": { + "type": "string", + "example": "Dogs" + } + }, + "xml": { + "name": "category" + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "username": { + "type": "string", + "example": "theUser" + }, + "firstName": { + "type": "string", + "example": "John" + }, + "lastName": { + "type": "string", + "example": "James" + }, + "email": { + "type": "string", + "example": "john@email.com" + }, + "password": { + "type": "string", + "example": "12345" + }, + "phone": { + "type": "string", + "example": "12345" + }, + "userStatus": { + "type": "integer", + "description": "User Status", + "format": "int32", + "example": 1 + } + }, + "xml": { + "name": "user" + } + }, + "Tag": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + } + }, + "xml": { + "name": "tag" + } + }, + "Pet": { + "required": [ + "name", + "photoUrls" + ], + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "example": 10 + }, + "name": { + "type": "string", + "example": "doggie" + }, + "category": { + "$ref": "#/components/schemas/Category" + }, + "photoUrls": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "type": "string", + "xml": { + "name": "photoUrl" + } + } + }, + "tags": { + "type": "array", + "xml": { + "wrapped": true + }, + "items": { + "$ref": "#/components/schemas/Tag" + } + }, + "status": { + "type": "string", + "description": "pet status in the store", + "enum": [ + "available", + "pending", + "sold" + ] + } + }, + "xml": { + "name": "pet" + } + }, + "ApiResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "type": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "xml": { + "name": "##default" + } + } + }, + "requestBodies": { + "Pet": { + "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" + } + } + } + }, + "UserArray": { + "description": "List of user object", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + } + }, + "securitySchemes": { + "petstore_auth": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://petstore3.swagger.io/oauth/authorize", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + }, + "api_key": { + "type": "apiKey", + "name": "api_key", + "in": "header" + } + } + } +} \ No newline at end of file diff --git a/packages/openapi-parser/src/fixtures/petstore.yaml b/packages/openapi-parser/src/fixtures/petstore.yaml new file mode 100644 index 000000000..70965806c --- /dev/null +++ b/packages/openapi-parser/src/fixtures/petstore.yaml @@ -0,0 +1,830 @@ +openapi: 3.0.4 +info: + title: Swagger Petstore - OpenAPI 3.0 + description: |- + This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about + Swagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach! + You can now help us improve the API whether it's by making changes to the definition itself or to the code. + That way, with time, we can improve the API in general, and expose some of the new features in OAS3. + + Some useful links: + - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore) + - [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml) + termsOfService: https://swagger.io/terms/ + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.26 +externalDocs: + description: Find out more about Swagger + url: https://swagger.io +servers: +- url: "/api/v3" +tags: +- name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: https://swagger.io +- name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: https://swagger.io +- name: user + description: Operations about user +paths: + "/pet": + put: + tags: + - pet + summary: Update an existing pet. + description: Update an existing pet by Id. + operationId: updatePet + requestBody: + description: Update an existent pet in the store + content: + application/json: + schema: + "$ref": "#/components/schemas/Pet" + application/xml: + schema: + "$ref": "#/components/schemas/Pet" + application/x-www-form-urlencoded: + schema: + "$ref": "#/components/schemas/Pet" + required: true + 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 + '422': + description: Validation exception + default: + description: Unexpected error + security: + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Add a new pet to the store. + description: Add a new pet to the store. + operationId: addPet + requestBody: + description: Create a new pet in the store + content: + application/json: + schema: + "$ref": "#/components/schemas/Pet" + application/xml: + schema: + "$ref": "#/components/schemas/Pet" + application/x-www-form-urlencoded: + schema: + "$ref": "#/components/schemas/Pet" + required: true + responses: + '200': + description: Successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/Pet" + application/xml: + schema: + "$ref": "#/components/schemas/Pet" + '400': + description: Invalid input + '422': + description: Validation exception + default: + description: Unexpected error + 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 + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: false + explode: true + schema: + type: string + default: available + enum: + - available + - pending + - sold + 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 + default: + description: Unexpected error + 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 + parameters: + - name: tags + in: query + description: Tags to filter by + required: false + 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 + default: + description: Unexpected error + security: + - petstore_auth: + - write:pets + - read:pets + "/pet/{petId}": + get: + tags: + - pet + summary: Find pet by ID. + description: Returns a single pet. + operationId: getPetById + 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 + default: + description: Unexpected error + security: + - api_key: [] + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Updates a pet in the store with form data. + description: Updates a pet resource based on the form data. + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + - name: name + in: query + description: Name of pet that needs to be updated + schema: + type: string + - name: status + in: query + description: Status of pet that needs to be updated + schema: + type: string + responses: + '200': + description: successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/Pet" + application/xml: + schema: + "$ref": "#/components/schemas/Pet" + '400': + description: Invalid input + default: + description: Unexpected error + security: + - petstore_auth: + - write:pets + - read:pets + delete: + tags: + - pet + summary: Deletes a pet. + description: Delete a pet. + operationId: deletePet + parameters: + - name: api_key + in: header + description: '' + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Pet deleted + '400': + description: Invalid pet value + default: + description: Unexpected error + security: + - petstore_auth: + - write:pets + - read:pets + "/pet/{petId}/uploadImage": + post: + tags: + - pet + summary: Uploads an image. + description: Upload image of the pet. + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + description: Additional Metadata + required: false + schema: + type: string + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + '200': + description: successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/ApiResponse" + '400': + description: No file uploaded + '404': + description: Pet not found + default: + description: Unexpected error + 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 + responses: + '200': + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + default: + description: Unexpected error + security: + - api_key: [] + "/store/order": + post: + tags: + - store + summary: Place an order for a pet. + description: Place a new order in the store. + operationId: placeOrder + requestBody: + content: + application/json: + schema: + "$ref": "#/components/schemas/Order" + application/xml: + schema: + "$ref": "#/components/schemas/Order" + application/x-www-form-urlencoded: + schema: + "$ref": "#/components/schemas/Order" + responses: + '200': + description: successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/Order" + '400': + description: Invalid input + '422': + description: Validation exception + default: + description: Unexpected error + "/store/order/{orderId}": + get: + tags: + - store + summary: Find purchase order by ID. + description: For valid response try integer IDs with value <= 5 or > 10. Other + values will generate exceptions. + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of order that needs to be fetched + required: true + schema: + type: integer + 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 + default: + description: Unexpected error + delete: + tags: + - store + summary: Delete purchase order by identifier. + description: For valid response try integer IDs with value < 1000. Anything + above 1000 or non-integers will generate API errors. + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: order deleted + '400': + description: Invalid ID supplied + '404': + description: Order not found + default: + description: Unexpected error + "/user": + post: + tags: + - user + summary: Create user. + description: This can only be done by the logged in user. + operationId: createUser + requestBody: + description: Created user object + content: + application/json: + schema: + "$ref": "#/components/schemas/User" + application/xml: + schema: + "$ref": "#/components/schemas/User" + application/x-www-form-urlencoded: + schema: + "$ref": "#/components/schemas/User" + responses: + '200': + description: successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/User" + application/xml: + schema: + "$ref": "#/components/schemas/User" + default: + description: Unexpected error + "/user/createWithList": + post: + tags: + - user + summary: Creates list of users with given input array. + description: Creates list of users with given input array. + operationId: createUsersWithListInput + requestBody: + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/User" + responses: + '200': + description: Successful operation + content: + application/json: + schema: + "$ref": "#/components/schemas/User" + application/xml: + schema: + "$ref": "#/components/schemas/User" + default: + description: Unexpected error + "/user/login": + get: + tags: + - user + summary: Logs user into the system. + description: Log into the system. + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: false + schema: + type: string + - name: password + in: query + description: The password for login in clear text + required: false + schema: + type: string + responses: + '200': + description: successful operation + headers: + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + '400': + description: Invalid username/password supplied + default: + description: Unexpected error + "/user/logout": + get: + tags: + - user + summary: Logs out current logged in user session. + description: Log user out of the system. + operationId: logoutUser + parameters: [] + responses: + '200': + description: successful operation + default: + description: Unexpected error + "/user/{username}": + get: + tags: + - user + summary: Get user by user name. + description: Get user detail based on username. + operationId: getUserByName + 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 + default: + description: Unexpected error + put: + tags: + - user + summary: Update user resource. + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that need to be deleted + required: true + schema: + type: string + requestBody: + description: Update an existent user in the store + content: + application/json: + schema: + "$ref": "#/components/schemas/User" + application/xml: + schema: + "$ref": "#/components/schemas/User" + application/x-www-form-urlencoded: + schema: + "$ref": "#/components/schemas/User" + responses: + '200': + description: successful operation + '400': + description: bad request + '404': + description: user not found + default: + description: Unexpected error + delete: + tags: + - user + summary: Delete user resource. + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + '200': + description: User deleted + '400': + description: Invalid username supplied + '404': + description: User not found + default: + description: Unexpected error +components: + schemas: + Order: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + example: approved + enum: + - placed + - approved + - delivered + complete: + type: boolean + xml: + name: order + Category: + type: object + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + xml: + name: category + User: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + username: + type: string + example: theUser + firstName: + type: string + example: John + lastName: + type: string + example: James + email: + type: string + example: john@email.com + password: + type: string + example: '12345' + phone: + type: string + example: '12345' + userStatus: + type: integer + description: User Status + format: int32 + example: 1 + xml: + name: user + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: tag + Pet: + required: + - name + - photoUrls + type: object + properties: + id: + type: integer + format: int64 + example: 10 + name: + type: string + example: doggie + category: + "$ref": "#/components/schemas/Category" + photoUrls: + type: array + xml: + wrapped: true + items: + type: string + xml: + name: photoUrl + tags: + type: array + xml: + wrapped: true + items: + "$ref": "#/components/schemas/Tag" + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + xml: + name: pet + ApiResponse: + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + xml: + name: "##default" + requestBodies: + Pet: + 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" + UserArray: + description: List of user object + content: + application/json: + schema: + type: array + items: + "$ref": "#/components/schemas/User" + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://petstore3.swagger.io/oauth/authorize + scopes: + write:pets: modify pets in your account + read:pets: read your pets + api_key: + type: apiKey + name: api_key + in: header diff --git a/packages/openapi-parser/src/parse.test.ts b/packages/openapi-parser/src/parse.test.ts index 7a40f054e..0d3ab70a8 100644 --- a/packages/openapi-parser/src/parse.test.ts +++ b/packages/openapi-parser/src/parse.test.ts @@ -4,26 +4,30 @@ import { parseOpenAPI } from './parse'; const spec = await Bun.file(new URL('./fixtures/recursive-spec.json', import.meta.url)).text(); const specV2 = await Bun.file(new URL('./fixtures/spec-v2.json', import.meta.url)).text(); +const petstoreyaml = await Bun.file(new URL('./fixtures/petstore.yaml', import.meta.url)).text(); +const petstoreInvalid = await Bun.file( + new URL('./fixtures/petstore-invalid.json', import.meta.url) +).text(); const html = ` API Documentation `; describe('#parseOpenAPI', () => { it('parses a recursive OpenAPI document', async () => { - const schema = await parseOpenAPI({ + const result = await parseOpenAPI({ value: spec, rootURL: null, }); // Ensure the structure returned is not recursive (not dereferenced). - JSON.stringify(schema); + JSON.stringify(result.filesystem); }); it('parses a swagger v2', async () => { - const schema = await parseOpenAPI({ + const result = await parseOpenAPI({ value: specV2, rootURL: null, }); // Ensure the structure returned is not recursive (not dereferenced). - JSON.stringify(schema); + JSON.stringify(result.filesystem); }); it('throws an error for invalid OpenAPI document', async () => { @@ -39,4 +43,23 @@ describe('#parseOpenAPI', () => { } } }); + + it('allows a document yaml', async () => { + const result = await parseOpenAPI({ + value: petstoreyaml, + rootURL: null, + }); + // Ensure the structure returned is not recursive (not dereferenced). + JSON.stringify(result.filesystem); + }); + + it('allows a document with errors', async () => { + const result = await parseOpenAPI({ + value: petstoreInvalid, + rootURL: null, + }); + // Ensure the structure returned is not recursive (not dereferenced). + JSON.stringify(result.filesystem); + expect(result.errors).toHaveLength(1); + }); }); diff --git a/packages/openapi-parser/src/parse.ts b/packages/openapi-parser/src/parse.ts index 88b6c686b..fc6eacd16 100644 --- a/packages/openapi-parser/src/parse.ts +++ b/packages/openapi-parser/src/parse.ts @@ -1,5 +1,6 @@ -import type { AnyObject, bundle } from '@scalar/openapi-parser'; +import type { AnyObject, ErrorObject, bundle } from '@scalar/openapi-parser'; import { OpenAPIParseError } from './error'; +import type { Filesystem, OpenAPIV3xDocument } from './types'; import { convertOpenAPIV2ToOpenAPIV3 } from './v2'; import { parseOpenAPIV3 } from './v3'; @@ -14,10 +15,6 @@ export interface ParseOpenAPIInput { * The root URL of the specified OpenAPI document. */ rootURL: string | null; - /** - * Trust the input. This will skip advanced validation. - */ - trust?: boolean; /** * Options for the parser. */ @@ -26,6 +23,18 @@ export interface ParseOpenAPIInput { }; } +export interface ParseOpenAPIResult { + /** + * Informational errors that were found while parsing the OpenAPI document. + */ + errors: ErrorObject[]; + + /** + * The parsed OpenAPI document. + */ + filesystem: Filesystem; +} + /** * Parse a raw string into an OpenAPI document. * It will also convert Swagger 2.0 to OpenAPI 3.0. diff --git a/packages/openapi-parser/src/v2.test.ts b/packages/openapi-parser/src/v2.test.ts index 39bfab8f1..c113cd373 100644 --- a/packages/openapi-parser/src/v2.test.ts +++ b/packages/openapi-parser/src/v2.test.ts @@ -5,12 +5,12 @@ const specV2 = await Bun.file(new URL('./fixtures/spec-v2.json', import.meta.url describe('#convertOpenAPIV2ToOpenAPIV3', () => { it('converts an OpenAPIV2 in V3', async () => { - const schema = await convertOpenAPIV2ToOpenAPIV3({ + const result = await convertOpenAPIV2ToOpenAPIV3({ value: specV2, rootURL: null, }); // Ensure the structure returned is not recursive (not dereferenced). - JSON.stringify(schema); - expect(schema[0]?.specification.openapi).toBe('3.1.1'); + JSON.stringify(result.filesystem); + expect(result.filesystem[0]?.specification.openapi).toBe('3.1.1'); }); }); diff --git a/packages/openapi-parser/src/v2.ts b/packages/openapi-parser/src/v2.ts index f73d5088a..8df7238ab 100644 --- a/packages/openapi-parser/src/v2.ts +++ b/packages/openapi-parser/src/v2.ts @@ -1,7 +1,6 @@ import { upgrade } from '@scalar/openapi-parser'; import { OpenAPIParseError } from './error'; -import type { ParseOpenAPIInput } from './parse'; -import type { Filesystem, OpenAPIV3xDocument } from './types'; +import type { ParseOpenAPIInput, ParseOpenAPIResult } from './parse'; import { parseOpenAPIV3 } from './v3'; /** @@ -9,24 +8,28 @@ import { parseOpenAPIV3 } from './v3'; */ export async function convertOpenAPIV2ToOpenAPIV3( input: ParseOpenAPIInput -): Promise> { - const { value, rootURL } = input; - - const upgradeResult = (() => { - try { - return upgrade(value); - } catch (error) { - if (error instanceof Error) { - throw new OpenAPIParseError('Failed to convert Swagger 2.0 to OpenAPI 3.1.1', { - code: 'v2-conversion', - rootURL, - cause: error, - }); - } - - throw error; - } - })(); - - return parseOpenAPIV3({ ...input, rootURL, value: upgradeResult.specification, trust: true }); +): Promise { + const result = upgradeFromInput(input); + return parseOpenAPIV3({ ...input, rootURL: input.rootURL, value: result.specification }); +} + +/** + * Upgrade a Swagger 2.0 schema to an OpenAPI 3.0 schema. + * This function will throw an error if the conversion fails. + */ +function upgradeFromInput(input: ParseOpenAPIInput) { + const { value, rootURL } = input; + try { + return upgrade(value); + } catch (error) { + if (error instanceof Error) { + throw new OpenAPIParseError('Failed to convert Swagger 2.0 to OpenAPI 3.1.1', { + code: 'v2-conversion', + rootURL, + cause: error, + }); + } + + throw error; + } } diff --git a/packages/openapi-parser/src/v3.ts b/packages/openapi-parser/src/v3.ts index e686ef0bc..184a3676e 100644 --- a/packages/openapi-parser/src/v3.ts +++ b/packages/openapi-parser/src/v3.ts @@ -1,38 +1,15 @@ import { validate } from '@scalar/openapi-parser'; import { OpenAPIParseError } from './error'; import { createFileSystem } from './filesystem'; -import type { ParseOpenAPIInput } from './parse'; -import type { Filesystem, OpenAPIV3xDocument } from './types'; +import type { ParseOpenAPIInput, ParseOpenAPIResult } from './parse'; /** * Parse a raw string into an OpenAPI document. * It will also convert Swagger 2.0 to OpenAPI 3.0. * It can throw an `OpenAPIFetchError` if the document is invalid. */ -export async function parseOpenAPIV3( - input: ParseOpenAPIInput -): Promise> { - const { value, rootURL, trust, options = {} } = input; - const specification = trust - ? await trustedValidate({ value, rootURL }) - : await untrustedValidate({ value, rootURL }); - - const filesystem = await createFileSystem({ - value: specification, - rootURL, - options, - }); - - return filesystem; -} - -type ValidateOpenAPIV3Input = Pick; - -/** - * Validate an untrusted OpenAPI v3 document. - */ -async function untrustedValidate(input: ValidateOpenAPIV3Input) { - const { value, rootURL } = input; +export async function parseOpenAPIV3(input: ParseOpenAPIInput): Promise { + const { value, rootURL, options = {} } = input; const result = await validate(value); if (result.version === '2.0') { @@ -42,8 +19,9 @@ async function untrustedValidate(input: ValidateOpenAPIV3Input) { }); } - // Spec is invalid, we stop here. - if (!result.valid) { + // We don't rely on `result.invalid` because it's too strict. + // If we succeed in parsing a schema, then we consider it valid. + if (!result.specification || !result.version) { throw new OpenAPIParseError('Invalid OpenAPI document', { code: 'invalid', rootURL, @@ -51,34 +29,11 @@ async function untrustedValidate(input: ValidateOpenAPIV3Input) { }); } - return result.specification; -} - -/** - * Validate a trusted OpenAPI v3 document. - * It assumes the specification is already a valid specification. - * It's faster than `untrustedValidate`. - */ -async function trustedValidate(input: ValidateOpenAPIV3Input) { - const { value, rootURL } = input; - const result = (() => { - if (typeof value === 'string') { - try { - return JSON.parse(value); - } catch (_error) { - /** In case of an invalid JSON, we fallback to untrusted validation. */ - return untrustedValidate(input); - } - } - return value; - })(); - - if ('swagger' in result && result.swagger) { - throw new OpenAPIParseError('Only OpenAPI v3 is supported', { - code: 'parse-v2-in-v3', - rootURL, - }); - } - - return result; + const filesystem = await createFileSystem({ + value: result.specification, + rootURL, + options, + }); + + return { filesystem, errors: result.errors ?? [] }; } diff --git a/packages/react-openapi/src/resolveOpenAPIOperation.test.ts b/packages/react-openapi/src/resolveOpenAPIOperation.test.ts index 1d12b5fbc..177d082bb 100644 --- a/packages/react-openapi/src/resolveOpenAPIOperation.test.ts +++ b/packages/react-openapi/src/resolveOpenAPIOperation.test.ts @@ -6,7 +6,7 @@ import { resolveOpenAPIOperation } from './resolveOpenAPIOperation'; async function fetchFilesystem(url: string) { const response = await fetch(url); const text = await response.text(); - const filesystem = await parseOpenAPI({ value: text, rootURL: url }); + const { filesystem } = await parseOpenAPI({ value: text, rootURL: url }); const transformedFs = await traverse(filesystem, async (node) => { if ('description' in node && typeof node.description === 'string' && node.description) { node['x-gitbook-description-html'] = node.description; diff --git a/packages/react-openapi/src/schemas/resolveOpenAPISchemas.test.ts b/packages/react-openapi/src/schemas/resolveOpenAPISchemas.test.ts index eceb9b57e..8169aea6a 100644 --- a/packages/react-openapi/src/schemas/resolveOpenAPISchemas.test.ts +++ b/packages/react-openapi/src/schemas/resolveOpenAPISchemas.test.ts @@ -6,7 +6,7 @@ import { resolveOpenAPISchemas } from './resolveOpenAPISchemas'; async function fetchFilesystem(url: string) { const response = await fetch(url); const text = await response.text(); - const filesystem = await parseOpenAPI({ value: text, rootURL: url }); + const { filesystem } = await parseOpenAPI({ value: text, rootURL: url }); const transformedFs = await traverse(filesystem, async (node) => { if ('description' in node && typeof node.description === 'string' && node.description) { node['x-gitbook-description-html'] = node.description;