Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

gRPC JSON transcoding + Microsoft.AspNetCore.OpenApi #56067

Closed
wants to merge 6 commits into from

Conversation

JamesNK
Copy link
Member

@JamesNK JamesNK commented Jun 4, 2024

Work in progress.

Note: I needed to make some changes to the IApiDescriptionProvider. Microsoft.AspNetCore.OpenApi uses ApiParameterDescription.Type in places that Swashbuckle used ApiParameterDescription.ModelMetadata.ModelType. I don't know if that change was intentional.

@JamesNK JamesNK added the area-grpc Includes: GRPC wire-up, templates label Jun 4, 2024
@JamesNK
Copy link
Member Author

JamesNK commented Jun 4, 2024

Example of Swashbuckle output:

{
  "openapi": "3.0.1",
  "info": {
    "title": "My API",
    "version": "v1"
  },
  "paths": {
    "/v1/greeter/{name}": {
      "get": {
        "tags": [
          "Greeter"
        ],
        "summary": "Say hello.",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "description": "Name to greet.",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HelloReply"
                }
              }
            }
          },
          "default": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Status"
                }
              }
            }
          }
        }
      }
    },
    "/v1/greeter": {
      "post": {
        "tags": [
          "Greeter"
        ],
        "summary": "Sends a greeting from someone.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HelloRequestFrom"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HelloReply"
                }
              }
            }
          },
          "default": {
            "description": "Error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Status"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Any": {
        "required": [
          "@type"
        ],
        "type": "object",
        "properties": {
          "@type": {
            "type": "string"
          }
        },
        "additionalProperties": {
          "$ref": "#/components/schemas/Value"
        }
      },
      "HelloReply": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string",
            "description": "Greeting message."
          },
          "nested": {
            "$ref": "#/components/schemas/HelloReply"
          }
        },
        "additionalProperties": false
      },
      "HelloRequestFrom": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string",
            "description": "Name to greet."
          },
          "from": {
            "type": "string",
            "description": "Greeting from."
          }
        },
        "additionalProperties": false
      },
      "KindOneofCase": {
        "enum": [
          0,
          1,
          2,
          3,
          4,
          5,
          6
        ],
        "type": "integer",
        "format": "int32"
      },
      "ListValue": {
        "type": "object",
        "properties": {
          "values": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Value"
            },
            "nullable": true,
            "readOnly": true
          }
        },
        "additionalProperties": false
      },
      "NullValue": {
        "enum": [
          0
        ],
        "type": "integer",
        "format": "int32"
      },
      "Status": {
        "type": "object",
        "properties": {
          "code": {
            "type": "integer",
            "format": "int32"
          },
          "message": {
            "type": "string"
          },
          "details": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Any"
            }
          }
        },
        "additionalProperties": false
      },
      "Struct": {
        "type": "object",
        "properties": {
          "fields": {
            "type": "object",
            "additionalProperties": {
              "$ref": "#/components/schemas/Value"
            },
            "nullable": true,
            "readOnly": true
          }
        },
        "additionalProperties": false
      },
      "Value": {
        "type": "object",
        "properties": {
          "nullValue": {
            "$ref": "#/components/schemas/NullValue"
          },
          "hasNullValue": {
            "type": "boolean",
            "readOnly": true
          },
          "numberValue": {
            "type": "number",
            "format": "double"
          },
          "hasNumberValue": {
            "type": "boolean",
            "readOnly": true
          },
          "stringValue": {
            "type": "string",
            "nullable": true
          },
          "hasStringValue": {
            "type": "boolean",
            "readOnly": true
          },
          "boolValue": {
            "type": "boolean"
          },
          "hasBoolValue": {
            "type": "boolean",
            "readOnly": true
          },
          "structValue": {
            "$ref": "#/components/schemas/Struct"
          },
          "listValue": {
            "$ref": "#/components/schemas/ListValue"
          },
          "kindCase": {
            "$ref": "#/components/schemas/KindOneofCase"
          }
        },
        "additionalProperties": false
      }
    }
  },
  "tags": [
    {
      "name": "Greeter",
      "description": "The greeting service definition."
    }
  ]
}

@JamesNK
Copy link
Member Author

JamesNK commented Jun 4, 2024

Example of current Microsoft.AspNetCore.OpenApi:

{
  "openapi": "3.0.1",
  "info": {
    "title": "Sandbox | v1",
    "version": "1.0.0"
  },
  "paths": {
    "/v1/greeter/{name}": {
      "get": {
        "tags": [
          "Greeter"
        ],
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { }
              }
            }
          },
          "default": {
            "description": "",
            "content": {
              "application/json": {
                "schema": { }
              }
            }
          }
        }
      }
    },
    "/v1/greeter": {
      "post": {
        "tags": [
          "Greeter"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "from": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": { }
              }
            }
          },
          "default": {
            "description": "",
            "content": {
              "application/json": {
                "schema": { }
              }
            }
          }
        }
      }
    }
  },
  "tags": [
    {
      "name": "Greeter"
    }
  ]
}

Schemas for complex objects are currently empty. Also, I couldn't find an example of enabling documentaiton.

@captainsafia captainsafia force-pushed the jamesnk/grpcjsontranscoding-openapi branch from 0a019a1 to 422fb18 Compare June 4, 2024 19:35
@JamesNK JamesNK force-pushed the jamesnk/grpcjsontranscoding-openapi branch from dbcff55 to 0650d87 Compare June 5, 2024 04:55
@JamesNK
Copy link
Member Author

JamesNK commented Jun 5, 2024

Microsoft.AspNetCore.OpenApi json after changes:

{
  "openapi": "3.0.1",
  "info": {
    "title": "Sandbox | v1",
    "version": "1.0.0"
  },
  "paths": {
    "/v1/greeter/{name}": {
      "get": {
        "tags": [
          "Greeter"
        ],
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string"
                    },
                    "nested": { }
                  }
                }
              }
            }
          },
          "default": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "code": {
                      "type": "integer",
                      "format": "int32"
                    },
                    "message": {
                      "type": "string"
                    },
                    "details": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "typeUrl": {
                            "type": "string"
                          },
                          "value": {
                            "type": "array",
                            "items": {
                              "type": "string",
                              "format": "byte"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/v1/greeter": {
      "post": {
        "tags": [
          "Greeter"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "name": {
                    "type": "string"
                  },
                  "from": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "message": {
                      "type": "string"
                    },
                    "nested": { }
                  }
                }
              }
            }
          },
          "default": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "code": {
                      "type": "integer",
                      "format": "int32"
                    },
                    "message": {
                      "type": "string"
                    },
                    "details": {
                      "type": "array",
                      "items": {
                        "type": "object",
                        "properties": {
                          "typeUrl": {
                            "type": "string"
                          },
                          "value": {
                            "type": "array",
                            "items": {
                              "type": "string",
                              "format": "byte"
                            }
                          }
                        }
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  },
  "tags": [
    {
      "name": "Greeter"
    }
  ]
}

Note: This is from running the sandbox project inside the gRPC solution view.

Gaps I see:

  • No ISerializerDataContractResolver equivalent. Needed to correctly resolve how types are serialized. For example, enum types need to use attribute values for their names, some complex types are serialized as simple types, property names can be customized, etc. Required to function properly.
  • No XML docs.
  • Complex objects are inline instead of in the components section. I think Swashbuckle's behavior of always putting complex objects in components and referencing is better. Always using references gives a nice list of types in the SwaggerUI, reduces duplication, and makes circular references a trivial problem.

@JamesNK JamesNK force-pushed the jamesnk/grpcjsontranscoding-openapi branch from 4519fc0 to 2139eed Compare July 2, 2024 10:24
@JamesNK
Copy link
Member Author

JamesNK commented Jul 2, 2024

I rebased to latest and looked at this more.


JsonSchemaExporter.GetJsonSchemaAsNode uses the type resolver. The OpenAPI integration loads it from JsonOptions. I updated transcoding to also set the custom type resolver onto JsonOptions. However, when I did this there was a regression in schema generation.

The nested property now references something that doesn't exist. I don't know why it does this.

          "nested": {
            "$ref": "#/components/schemas/#/properties/nested"
          }

Also, there are duplicated definitions in the references. e.g. HelloReply1, HelloReply2, HelloReply3, etc. I'm guessing there is something wrong with the schema comparer that causes them not to be equal.

Every time I refresh the OpenAPI document on the server, the reference numbers increase. For example, HelloReply starts at HelloReply1 and quickly increases to HelloReply12 after a few refreshes. The cause is the problem above, but I also don't think you should cache schema references between document generations.

For example, imagine there are two OpenApi docs and each have their own class called Version: System.Version and Consoto.Version. Each will be referenced as Version but the number added to the reference them will depend on the order the docs are generated. IMO the documents should be independent of each other.


I noticed arrays are a referenced type, e.g. ArrayOfAny. I don't think you should try to store references to arrays. They're almost always very simple as the items are either a primitive type or a reference. e.g.

{
    "type": "array",
    "items": {
        "$ref": "#/components/schemas/Value"
    },
    "nullable": true,
    "readOnly": true
}

If someone wants to view the JSON for a collection, having to look up an array reference to then look at the array's item reference makes the JSON harder to read than necessary.

Swashbuckle doesn't add array/collection references.


Current JSON:

{
  "openapi": "3.0.1",
  "info": {
    "title": "Sandbox | v1",
    "version": "1.0.0"
  },
  "paths": {
    "/v1/greeter/{name}": {
      "get": {
        "tags": [
          "Greeter"
        ],
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HelloReply17"
                }
              }
            }
          },
          "default": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Status9"
                }
              }
            }
          }
        }
      }
    },
    "/v1/greeter": {
      "post": {
        "tags": [
          "Greeter"
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HelloRequestFrom9"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HelloReply17"
                }
              }
            }
          },
          "default": {
            "description": "",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Status9"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "ArrayOfAny": {
        "type": "array",
        "nullable": true
      },
      "HelloReply": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/HelloReply16"
          }
        }
      },
      "HelloReply10": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/#/properties/nested"
          }
        },
        "nullable": true
      },
      "HelloReply11": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/HelloReply16"
          }
        }
      },
      "HelloReply12": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/#/properties/nested"
          }
        },
        "nullable": true
      },
      "HelloReply13": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/HelloReply16"
          }
        }
      },
      "HelloReply14": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/#/properties/nested"
          }
        },
        "nullable": true
      },
      "HelloReply15": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/HelloReply16"
          }
        }
      },
      "HelloReply16": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/#/properties/nested"
          }
        },
        "nullable": true
      },
      "HelloReply17": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/HelloReply18"
          }
        }
      },
      "HelloReply18": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/#/properties/nested"
          }
        },
        "nullable": true
      },
      "HelloReply2": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/#/properties/nested"
          }
        },
        "nullable": true
      },
      "HelloReply3": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/HelloReply16"
          }
        }
      },
      "HelloReply4": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/#/properties/nested"
          }
        },
        "nullable": true
      },
      "HelloReply5": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/HelloReply16"
          }
        }
      },
      "HelloReply6": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/#/properties/nested"
          }
        },
        "nullable": true
      },
      "HelloReply7": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/HelloReply16"
          }
        }
      },
      "HelloReply8": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/#/properties/nested"
          }
        },
        "nullable": true
      },
      "HelloReply9": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "nested": {
            "$ref": "#/components/schemas/HelloReply16"
          }
        }
      },
      "HelloRequestFrom": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "from": {
            "type": "string"
          }
        }
      },
      "HelloRequestFrom2": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "from": {
            "type": "string"
          }
        }
      },
      "HelloRequestFrom3": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "from": {
            "type": "string"
          }
        }
      },
      "HelloRequestFrom4": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "from": {
            "type": "string"
          }
        }
      },
      "HelloRequestFrom5": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "from": {
            "type": "string"
          }
        }
      },
      "HelloRequestFrom6": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "from": {
            "type": "string"
          }
        }
      },
      "HelloRequestFrom7": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "from": {
            "type": "string"
          }
        }
      },
      "HelloRequestFrom8": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "from": {
            "type": "string"
          }
        }
      },
      "HelloRequestFrom9": {
        "type": "object",
        "properties": {
          "name": {
            "type": "string"
          },
          "from": {
            "type": "string"
          }
        }
      },
      "Status": {
        "type": "object",
        "properties": {
          "code": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "message": {
            "type": "string"
          },
          "details": {
            "$ref": "#/components/schemas/ArrayOfAny"
          }
        }
      },
      "Status2": {
        "type": "object",
        "properties": {
          "code": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "message": {
            "type": "string"
          },
          "details": {
            "$ref": "#/components/schemas/ArrayOfAny"
          }
        }
      },
      "Status3": {
        "type": "object",
        "properties": {
          "code": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "message": {
            "type": "string"
          },
          "details": {
            "$ref": "#/components/schemas/ArrayOfAny"
          }
        }
      },
      "Status4": {
        "type": "object",
        "properties": {
          "code": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "message": {
            "type": "string"
          },
          "details": {
            "$ref": "#/components/schemas/ArrayOfAny"
          }
        }
      },
      "Status5": {
        "type": "object",
        "properties": {
          "code": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "message": {
            "type": "string"
          },
          "details": {
            "$ref": "#/components/schemas/ArrayOfAny"
          }
        }
      },
      "Status6": {
        "type": "object",
        "properties": {
          "code": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "message": {
            "type": "string"
          },
          "details": {
            "$ref": "#/components/schemas/ArrayOfAny"
          }
        }
      },
      "Status7": {
        "type": "object",
        "properties": {
          "code": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "message": {
            "type": "string"
          },
          "details": {
            "$ref": "#/components/schemas/ArrayOfAny"
          }
        }
      },
      "Status8": {
        "type": "object",
        "properties": {
          "code": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "message": {
            "type": "string"
          },
          "details": {
            "$ref": "#/components/schemas/ArrayOfAny"
          }
        }
      },
      "Status9": {
        "type": "object",
        "properties": {
          "code": {
            "type": "integer",
            "format": "int32",
            "nullable": true
          },
          "message": {
            "type": "string"
          },
          "details": {
            "$ref": "#/components/schemas/ArrayOfAny"
          }
        }
      }
    }
  },
  "tags": [
    {
      "name": "Greeter"
    }
  ]
}

@martincostello
Copy link
Member

Also, there are duplicated definitions in the references. e.g. HelloReply1, HelloReply2, HelloReply3, etc. I'm guessing there is something wrong with the schema comparer that causes them not to be equal.

Every time I refresh the OpenAPI document on the server, the reference numbers increase. For example, HelloReply starts at HelloReply1 and quickly increases to HelloReply12 after a few refreshes. The cause is the problem above, but I also don't think you should cache schema references between document generations.

I think that's #56541.

@JamesNK
Copy link
Member Author

JamesNK commented Sep 6, 2024

Paused until schema generator customization improves.

@JamesNK JamesNK closed this Sep 6, 2024
@dotnet-policy-service dotnet-policy-service bot added this to the 10.0-preview1 milestone Sep 6, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-grpc Includes: GRPC wire-up, templates
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants