diff --git a/atelier-openapi/Cargo.toml b/atelier-openapi/Cargo.toml index 3d3e707..3e0ec7b 100644 --- a/atelier-openapi/Cargo.toml +++ b/atelier-openapi/Cargo.toml @@ -17,4 +17,12 @@ targets = ["x86_64-unknown-linux-gnu"] all-features = true [dependencies] -atelier_core = { version = "~0.2", path = "../atelier-core" } \ No newline at end of file +atelier_core = { version = "~0.2", path = "../atelier-core" } +okapi = "0.7.0-rc.1" +serde_json = "1.0" + +[dev-dependencies] +atelier_test = { version = "0.1", path = "../atelier-test" } +atelier_smithy = { version = "~0.2", path = "../atelier-smithy" } +atelier_json = { version = "~0.2", path = "../atelier-json" } +pretty_assertions = "1.1" \ No newline at end of file diff --git a/atelier-openapi/src/lib.rs b/atelier-openapi/src/lib.rs index 6b829bc..b05449e 100644 --- a/atelier-openapi/src/lib.rs +++ b/atelier-openapi/src/lib.rs @@ -1,18 +1,34 @@ -/*! -Future home of the OpenAPI writer for Smithy. +use std::cell::RefCell; +use std::io::Write; +use std::str::FromStr; -*/ - -// use ... +use atelier_core::error::Error as ModelError; +use atelier_core::model::shapes::{AppliedTraits, Service}; +use atelier_core::model::values::Value; +use atelier_core::model::visitor::walk_model; +use atelier_core::model::{Model, ShapeID}; +use atelier_core::{io::ModelWriter, model::visitor::ModelVisitor}; +use okapi::openapi3; +use serde_json::to_writer_pretty; // ------------------------------------------------------------------------------------------------ // Public Types // ------------------------------------------------------------------------------------------------ +/// +/// Writes out a model in the [OpenAPI](https://swagger.io/specification/) format. +/// +#[derive(Debug, Default)] +pub struct OpenApiWriter {} + // ------------------------------------------------------------------------------------------------ // Private Types // ------------------------------------------------------------------------------------------------ +struct OpenApiModelVisitor { + spec: RefCell, +} + // ------------------------------------------------------------------------------------------------ // Public Functions // ------------------------------------------------------------------------------------------------ @@ -21,10 +37,74 @@ Future home of the OpenAPI writer for Smithy. // Implementations // ------------------------------------------------------------------------------------------------ +impl ModelWriter for OpenApiWriter { + fn write(&mut self, w: &mut impl Write, model: &Model) -> atelier_core::error::Result<()> { + let visitor = OpenApiModelVisitor { + spec: RefCell::new(openapi3::OpenApi { + openapi: "3.0.2".to_string(), + ..openapi3::OpenApi::default() + }), + }; + walk_model(model, &visitor)?; + + to_writer_pretty(w, &visitor.spec.into_inner()); + + Ok(()) + } +} + +impl OpenApiModelVisitor { + fn add_info_object(&self, title: String, version: String) { + let mut spec = self.spec.borrow_mut(); + + spec.info = openapi3::Info { + version, + title, + ..openapi3::Info::default() + }; + } +} + +impl ModelVisitor for OpenApiModelVisitor { + type Error = ModelError; + + fn service( + &self, + id: &ShapeID, + traits: &AppliedTraits, + shape: &Service, + ) -> Result<(), Self::Error> { + let title_trait_id = &ShapeID::from_str("smithy.api#title").unwrap(); + + let title = if traits.contains_key(title_trait_id) { + expect_string_trait_value(traits, title_trait_id) + } else { + format!("{}", id.shape_name()) + }; + + let version = shape.version().clone(); + + self.add_info_object(title, version); + + Ok(()) + } +} + // ------------------------------------------------------------------------------------------------ // Private Functions // ------------------------------------------------------------------------------------------------ +fn expect_string_trait_value(traits: &AppliedTraits, trait_id: &ShapeID) -> String { + if let Some(Some(trait_value)) = traits.get(trait_id) { + match trait_value { + Value::String(s) => s.clone(), + v => panic!("Expected trait {} to be a string but was: {}", trait_id, v), + } + } else { + panic!("Expected trait {} not found", trait_id) + } +} + // ------------------------------------------------------------------------------------------------ // Modules // ------------------------------------------------------------------------------------------------ diff --git a/atelier-openapi/tests/describe_openapi_writer.rs b/atelier-openapi/tests/describe_openapi_writer.rs new file mode 100644 index 0000000..1998d39 --- /dev/null +++ b/atelier-openapi/tests/describe_openapi_writer.rs @@ -0,0 +1,44 @@ +use atelier_core::io::{read_model_from_file, write_model_to_string}; +use atelier_json::JsonReader; +use atelier_openapi::OpenApiWriter; +use atelier_smithy::SmithyReader; +use okapi::openapi3; +use pretty_assertions::assert_eq; +use std::{ffi::OsStr, fs, path::PathBuf}; + +#[test] +fn test_service() { + test("test-service.json"); +} + +#[test] +fn test_unions() { + test("union-test.smithy"); +} + +const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR"); + +// test helper which reads either a smithy idl file or smithy json file, converts it +// to openapi, and compares with the corresponding openapi spec from the /models directory. +fn test(file_name: &str) { + let source_file = PathBuf::from(format!("{}/tests/models/{}", MANIFEST_DIR, file_name)); + let expected_file_path = source_file.with_extension("openapi.json"); + + let extension = source_file.extension().and_then(OsStr::to_str).unwrap(); + + let model = match extension { + "smithy" => read_model_from_file(&mut SmithyReader::default(), source_file), + "json" => read_model_from_file(&mut JsonReader::default(), source_file), + _ => panic!("test input extension must be .smithy or .json"), + } + .unwrap(); + + let mut writer = OpenApiWriter::default(); + let actual_str = write_model_to_string(&mut writer, &model).unwrap(); + let actual_spec: openapi3::OpenApi = serde_json::from_str(&actual_str).unwrap(); + + let expected_file = fs::read_to_string(expected_file_path).unwrap(); + let expected_spec: openapi3::OpenApi = serde_json::from_str(&expected_file).unwrap(); + + assert_eq!(actual_spec, expected_spec); +} diff --git a/atelier-openapi/tests/models/test-service.json b/atelier-openapi/tests/models/test-service.json new file mode 100644 index 0000000..9d261bc --- /dev/null +++ b/atelier-openapi/tests/models/test-service.json @@ -0,0 +1,356 @@ +{ + "smithy": "1.0", + "shapes": { + "example.rest#RestService": { + "type": "service", + "version": "2006-03-01", + "operations": [ + { + "target": "example.rest#CreateDocument" + }, + { + "target": "example.rest#PutPayload" + }, + { + "target": "example.rest#CreateFoo" + }, + { + "target": "example.rest#Ping" + } + + ], + "traits": { + "aws.protocols#restJson1": {} + } + }, + "example.rest#PutPayload": { + "type": "operation", + "input": { + "target": "example.rest#PutPayloadInput" + }, + "output": { + "target": "example.rest#PutPayloadOutput" + }, + "errors": [ + { + "target": "example.rest#PayloadNotFound" + } + ], + "traits": { + "smithy.api#idempotent": {}, + "smithy.api#deprecated": {}, + "smithy.api#http": { + "uri": "/payload/{path}", + "method": "PUT" + } + } + }, + "example.rest#PutPayloadInput": { + "type": "structure", + "members": { + "path": { + "target": "example.rest#String", + "traits": { + "smithy.api#httpLabel": {}, + "smithy.api#required": {} + } + }, + "header": { + "target": "example.rest#String", + "traits": { + "smithy.api#httpHeader": "X-Foo-Header" + } + }, + "query": { + "target": "example.rest#Integer", + "traits": { + "smithy.api#httpQuery": "query" + } + }, + "timeQuery": { + "target": "smithy.api#Timestamp", + "traits": { + "smithy.api#httpQuery": "timeQuery" + } + }, + "enum": { + "target": "example.rest#EnumString", + "traits": { + "smithy.api#httpHeader": "X-EnumString" + } + }, + "body": { + "target": "example.rest#Blob", + "traits": { + "smithy.api#httpPayload": {}, + "smithy.api#required": {} + } + } + } + }, + "example.rest#PutPayloadOutput": { + "type": "structure", + "members": { + "header": { + "target": "example.rest#String", + "traits": { + "smithy.api#httpHeader": "X-Foo-Header" + } + }, + "body": { + "target": "example.rest#Blob", + "traits": { + "smithy.api#httpPayload": {} + } + } + } + }, + "example.rest#PayloadNotFound": { + "type": "structure", + "traits": { + "smithy.api#error": "client", + "smithy.api#httpError": 404 + } + }, + "example.rest#CreateDocument": { + "type": "operation", + "input": { + "target": "example.rest#CreateDocumentInput" + }, + "output": { + "target": "example.rest#CreateDocumentOutput" + }, + "traits": { + "smithy.api#http": { + "uri": "/document", + "method": "POST" + } + } + }, + "example.rest#CreateDocumentInput": { + "type": "structure", + "members": { + "query": { + "target": "example.rest#QueryList", + "traits": { + "smithy.api#httpQuery": "query" + } + }, + "abc": { + "target": "example.rest#String", + "traits": { + "smithy.api#sensitive": {} + } + }, + "def": { + "target": "example.rest#Integer" + }, + "hij": { + "target": "example.rest#Map" + }, + "stringDateTime": { + "target": "example.rest#StringDateTime" + }, + "required": { + "target": "example.rest#String", + "traits": { + "smithy.api#required": {} + } + }, + "queryParams": { + "target": "example.rest#QueryMap", + "traits": { + "smithy.api#httpQueryParams": {} + } + } + } + }, + "example.rest#CreateDocumentOutput": { + "type": "structure", + "members": { + "foo": { + "target": "example.rest#String" + }, + "baz": { + "target": "example.rest#String" + }, + "list": { + "target": "example.rest#List" + }, + "time": { + "target": "example.rest#Timestamp", + "traits": { + "smithy.api#required": {} + } + }, + "taggedUnion": { + "target": "example.rest#TaggedUnion" + } + } + }, + "example.rest#CreateFoo": { + "type": "operation", + "input": { + "target": "example.rest#CreateFooInput" + }, + "output": { + "target": "example.rest#CreateFooOutput" + }, + "traits": { + "smithy.api#http": { + "uri": "/foo", + "method": "POST" + } + } + }, + "example.rest#CreateFooInput": { + "type": "structure", + "members": { + "queryParams": { + "target": "example.rest#QueryStringListMap", + "traits": { + "smithy.api#httpQueryParams": {} + } + }, + "foo": { + "target": "example.rest#String" + } + } + }, + "example.rest#CreateFooOutput": { + "type": "structure", + "members": { + "foo": { + "target": "example.rest#String" + } + } + + }, + "example.rest#Ping": { + "type": "operation", + "input": { + "target": "smithy.api#Unit" + }, + "output": { + "target": "smithy.api#Unit" + }, + "traits": { + "smithy.api#http": { + "uri": "/ping", + "method": "POST" + } + } + }, + "example.rest#Blob": { + "type": "blob" + }, + "example.rest#String": { + "type": "string" + }, + "example.rest#StringList": { + "type": "list", + "member": { + "target": "example.rest#String" + } + }, + "example.rest#Integer": { + "type": "integer" + }, + "example.rest#Map": { + "type": "map", + "key": { + "target": "example.rest#String", + "traits": { + "smithy.api#length": { + "min": 2, + "max": 10 + } + } + }, + "value": { + "target": "example.rest#String", + "traits": { + "smithy.api#sensitive": {} + } + } + }, + "example.rest#List": { + "type": "list", + "member": { + "target": "example.rest#String" + }, + "traits": { + "smithy.api#length": { + "min": 0, + "max": 10 + } + } + }, + "example.rest#QueryList": { + "type": "list", + "member": { + "target": "example.rest#String", + "traits": { + "smithy.api#documentation": "Query list member reference docs!" + } + }, + "traits": { + "smithy.api#documentation": "Query list docs!", + "smithy.api#length": { + "min": 0, + "max": 10 + } + } + }, + "example.rest#QueryMap": { + "type": "map", + "key": { + "target": "example.rest#String" + }, + "value": { + "target": "example.rest#String" + } + }, + "example.rest#QueryStringListMap": { + "type": "map", + "key": { + "target": "example.rest#String" + }, + "value": { + "target": "example.rest#StringList" + } + }, + "example.rest#Timestamp": { + "type": "timestamp" + }, + "example.rest#StringDateTime": { + "type": "timestamp", + "traits": { + "smithy.api#timestampFormat": "date-time" + } + }, + "example.rest#EnumString": { + "type": "string", + "traits": { + "smithy.api#enum": [ + {"value": "a", "name": "A"}, + {"value": "c", "name": "C"} + ] + } + }, + "example.rest#TaggedUnion": { + "type": "union", + "members": { + "a": { + "target": "example.rest#String" + }, + "b": { + "target": "example.rest#String" + }, + "c": { + "target": "smithy.api#Unit" + } + } + } + } +} diff --git a/atelier-openapi/tests/models/test-service.openapi.json b/atelier-openapi/tests/models/test-service.openapi.json new file mode 100644 index 0000000..2d9d10e --- /dev/null +++ b/atelier-openapi/tests/models/test-service.openapi.json @@ -0,0 +1,343 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "RestService", + "version": "2006-03-01" + }, + "paths": { + "/document": { + "post": { + "operationId": "CreateDocument", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDocumentRequestContent" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "query", + "in": "query", + "description": "Query list docs!", + "style": "form", + "schema": { + "type": "array", + "items": { + "type": "string", + "description": "Query list member reference docs!" + }, + "maxItems": 10, + "minItems": 0, + "description": "Query list docs!" + }, + "explode": true + }, + { + "name": "queryParams", + "in": "query", + "style": "form", + "schema": { + "$ref": "#/components/schemas/QueryMap" + } + } + ], + "responses": { + "200": { + "description": "CreateDocument 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateDocumentResponseContent" + } + } + } + } + } + } + }, + "/foo": { + "post": { + "operationId": "CreateFoo", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFooRequestContent" + } + } + } + }, + "parameters": [ + { + "name": "queryParams", + "in": "query", + "style": "form", + "schema": { + "$ref": "#/components/schemas/QueryStringListMap" + }, + "explode": true + } + ], + "responses": { + "200": { + "description": "CreateFoo 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFooResponseContent" + } + } + } + } + } + } + }, + "/payload/{path}": { + "put": { + "operationId": "PutPayload", + "deprecated": true, + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/PutPayloadInputPayload" + } + } + }, + "required": true + }, + "parameters": [ + { + "name": "path", + "in": "path", + "schema": { + "type": "string" + }, + "required": true + }, + { + "name": "query", + "in": "query", + "schema": { + "type": "number", + "format": "int32" + } + }, + { + "name": "timeQuery", + "in": "query", + "schema": { + "type": "string", + "format": "date-time" + } + }, + { + "name": "X-EnumString", + "in": "header", + "schema": { + "$ref": "#/components/schemas/EnumString" + } + }, + { + "name": "X-Foo-Header", + "in": "header", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "PutPayload 200 response", + "headers": { + "X-Foo-Header": { + "schema": { + "type": "string" + } + } + }, + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/PutPayloadOutputPayload" + } + } + } + }, + "404": { + "description": "PayloadNotFound 404 response" + } + } + } + }, + "/ping": { + "post": { + "operationId": "Ping", + "responses": { + "200": { + "description": "Ping 200 response" + } + } + } + } + }, + "components": { + "schemas": { + "CreateDocumentRequestContent": { + "type": "object", + "properties": { + "abc": { + "type": "string", + "format": "password" + }, + "def": { + "type": "number", + "format": "int32" + }, + "hij": { + "$ref": "#/components/schemas/Map" + }, + "stringDateTime": { + "type": "string", + "format": "date-time" + }, + "required": { + "type": "string" + } + }, + "required": [ + "required" + ] + }, + "CreateDocumentResponseContent": { + "type": "object", + "properties": { + "taggedUnion": { + "$ref": "#/components/schemas/TaggedUnion" + }, + "foo": { + "type": "string" + }, + "baz": { + "type": "string" + }, + "time": { + "type": "number", + "format": "double" + }, + "list": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 10, + "minItems": 0 + } + }, + "required": [ + "time" + ] + }, + "CreateFooRequestContent": { + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } + }, + "CreateFooResponseContent": { + "type": "object", + "properties": { + "foo": { + "type": "string" + } + } + }, + "EnumString": { + "type": "string", + "enum": [ + "a", + "c" + ] + }, + "Map": { + "type": "object", + "additionalProperties": { + "type": "string", + "format": "password" + } + }, + "PutPayloadInputPayload": { + "type": "string", + "format": "byte" + }, + "PutPayloadOutputPayload": { + "type": "string", + "format": "byte" + }, + "QueryMap": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "QueryStringListMap": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "TaggedUnion": { + "oneOf": [ + { + "type": "object", + "title": "a", + "properties": { + "a": { + "type": "string" + } + }, + "required": [ + "a" + ] + }, + { + "type": "object", + "title": "b", + "properties": { + "b": { + "type": "string" + } + }, + "required": [ + "b" + ] + }, + { + "type": "object", + "title": "c", + "properties": { + "c": { + "$ref": "#/components/schemas/Unit" + } + }, + "required": [ + "c" + ] + } + ] + }, + "Unit": { + "type": "object" + } + } + } +} diff --git a/atelier-openapi/tests/models/union-test.openapi.json b/atelier-openapi/tests/models/union-test.openapi.json new file mode 100644 index 0000000..82e4c16 --- /dev/null +++ b/atelier-openapi/tests/models/union-test.openapi.json @@ -0,0 +1,61 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Example", + "version": "2020-09-11" + }, + "paths": { + "/": { + "get": { + "operationId": "GetItem", + "responses": { + "200": { + "description": "GetItem 200 response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemResponse" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Foo": { + "type": "object" + }, + "ItemResponse": { + "oneOf": [ + { + "type": "object", + "title": "Foo", + "properties": { + "Foo": { + "$ref": "#/components/schemas/Foo" + } + }, + "required": ["Foo"] + } + ] + } + }, + "securitySchemes": { + "aws.auth.sigv4": { + "type": "apiKey", + "description": "AWS Signature Version 4 authentication", + "name": "Authorization", + "in": "header", + "x-amazon-apigateway-authtype": "awsSigv4" + } + } + }, + "security": [ + { + "aws.auth.sigv4": [] + } + ] +} diff --git a/atelier-openapi/tests/models/union-test.smithy b/atelier-openapi/tests/models/union-test.smithy new file mode 100644 index 0000000..de3a61a --- /dev/null +++ b/atelier-openapi/tests/models/union-test.smithy @@ -0,0 +1,36 @@ +$version: "1.0" + +namespace smithy.example + +use aws.api#service +use aws.auth#sigv4 +use aws.protocols#restJson1 + +@title("Example") +@service(sdkId: "Example") +@sigv4(name: "Example") +@restJson1 +service Example { + version: "2020-09-11", + operations: [GetItem], +} + +@http(uri: "/", method: "GET") +@readonly +operation GetItem { + input: GetItemRequest, + output: GetItemResponse, +} + +structure GetItemRequest {} + +structure GetItemResponse { + @httpPayload + item: ItemResponse +} + +union ItemResponse { + Foo: Foo, +} + +structure Foo {}