From f99466009f6d007491f3bafe7756c5764d9720aa Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Mon, 17 Aug 2020 13:28:11 -0700 Subject: [PATCH 01/17] Test Elixir support for DSL concept --- lib/open_api_spex/operation_dsl.ex | 21 +++++++++++++++++++++ test/operation_dsl_test.exs | 18 ++++++++++++++++++ test/support/dsl_controller.ex | 20 ++++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 lib/open_api_spex/operation_dsl.ex create mode 100644 test/operation_dsl_test.exs create mode 100644 test/support/dsl_controller.ex diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/operation_dsl.ex new file mode 100644 index 00000000..4369f027 --- /dev/null +++ b/lib/open_api_spex/operation_dsl.ex @@ -0,0 +1,21 @@ +defmodule OpenApiSpex.OperationDsl do + defmacro operation(action, spec1) do + operation_def(action, spec1) + end + + def operation_def(action, spec1) do + quote do + if !Module.get_attribute(__MODULE__, :operation_defined) do + Module.register_attribute(__MODULE__, :spec_attributes, accumulate: true) + + @operation_defined true + + def spec_attributes do + @spec_attributes + end + end + + Module.put_attribute(__MODULE__, :spec_attributes, {unquote(action), unquote(spec1)}) + end + end +end diff --git a/test/operation_dsl_test.exs b/test/operation_dsl_test.exs new file mode 100644 index 00000000..336f40a8 --- /dev/null +++ b/test/operation_dsl_test.exs @@ -0,0 +1,18 @@ +defmodule OpenApiSpex.OperationDslTest do + use ExUnit.Case, async: true + + alias OpenApiSpexTest.DslController + # alias OpenApiSpex.OperationDsl + + test "operation/1" do + assert DslController.spec_attributes() == [summary: "Show user"] + end + + # test "operation_def/2" do + # ast = OperationDsl.operation_def(:index, :foo) + # # IO.inspect(ast, label: "ast") + + # code = Macro.to_string(ast) + # IO.puts(code) + # end +end diff --git a/test/support/dsl_controller.ex b/test/support/dsl_controller.ex new file mode 100644 index 00000000..e4211c2d --- /dev/null +++ b/test/support/dsl_controller.ex @@ -0,0 +1,20 @@ +defmodule OpenApiSpexTest.DslController do + use Phoenix.Controller + + import OpenApiSpex.OperationDsl + + alias OpenApiSpexTest.Schemas + + # operation(:show, summary: "Show user") + operation(:show, :foo) + + def show(conn, %{id: id}) do + json(conn, %Schemas.UserResponse{ + data: %Schemas.User{ + id: id, + name: "joe user", + email: "joe@gmail.com" + } + }) + end +end From 9ce28507e1d7fbe0b11ef8dd3474d4f72b4d7f2b Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Mon, 17 Aug 2020 13:55:33 -0700 Subject: [PATCH 02/17] Demonstrate defining attributes for each controller action --- lib/open_api_spex/operation_dsl.ex | 14 +++++++++----- test/operation_dsl_test.exs | 13 ++++--------- test/support/dsl_controller.ex | 17 +++++++++++++++-- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/operation_dsl.ex index 4369f027..49a215cb 100644 --- a/lib/open_api_spex/operation_dsl.ex +++ b/lib/open_api_spex/operation_dsl.ex @@ -3,19 +3,23 @@ defmodule OpenApiSpex.OperationDsl do operation_def(action, spec1) end + defmacro before_compile(_env) do + quote do + def spec_attributes, do: @spec_attributes + end + end + def operation_def(action, spec1) do quote do if !Module.get_attribute(__MODULE__, :operation_defined) do Module.register_attribute(__MODULE__, :spec_attributes, accumulate: true) - @operation_defined true + @before_compile {OpenApiSpex.OperationDsl, :before_compile} - def spec_attributes do - @spec_attributes - end + @operation_defined true end - Module.put_attribute(__MODULE__, :spec_attributes, {unquote(action), unquote(spec1)}) + @spec_attributes {unquote(action), unquote(spec1)} end end end diff --git a/test/operation_dsl_test.exs b/test/operation_dsl_test.exs index 336f40a8..3073fce1 100644 --- a/test/operation_dsl_test.exs +++ b/test/operation_dsl_test.exs @@ -5,14 +5,9 @@ defmodule OpenApiSpex.OperationDslTest do # alias OpenApiSpex.OperationDsl test "operation/1" do - assert DslController.spec_attributes() == [summary: "Show user"] + assert DslController.spec_attributes() == [ + {:show, [summary: "Show user"]}, + {:index, [summary: "User index"]} + ] end - - # test "operation_def/2" do - # ast = OperationDsl.operation_def(:index, :foo) - # # IO.inspect(ast, label: "ast") - - # code = Macro.to_string(ast) - # IO.puts(code) - # end end diff --git a/test/support/dsl_controller.ex b/test/support/dsl_controller.ex index e4211c2d..a613d30a 100644 --- a/test/support/dsl_controller.ex +++ b/test/support/dsl_controller.ex @@ -5,8 +5,21 @@ defmodule OpenApiSpexTest.DslController do alias OpenApiSpexTest.Schemas - # operation(:show, summary: "Show user") - operation(:show, :foo) + operation(:index, summary: "User index") + + def index(conn, _params) do + json(conn, %Schemas.UserResponse{ + data: [ + %Schemas.User{ + id: "abc123", + name: "joe user", + email: "joe@gmail.com" + } + ] + }) + end + + operation(:show, summary: "Show user") def show(conn, %{id: id}) do json(conn, %Schemas.UserResponse{ From f7c990896c23ee9a24fcc1bb504bf2dfc7e1faab Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Mon, 17 Aug 2020 15:10:37 -0700 Subject: [PATCH 03/17] Turn spec into an Operation struct --- lib/open_api_spex/operation_dsl.ex | 12 ++++++++---- test/operation_dsl_test.exs | 15 ++++++++++----- test/support/dsl_controller.ex | 4 ++-- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/operation_dsl.ex index 49a215cb..85fe282a 100644 --- a/lib/open_api_spex/operation_dsl.ex +++ b/lib/open_api_spex/operation_dsl.ex @@ -1,6 +1,6 @@ defmodule OpenApiSpex.OperationDsl do - defmacro operation(action, spec1) do - operation_def(action, spec1) + defmacro operation(action, spec) do + operation_def(action, spec) end defmacro before_compile(_env) do @@ -9,7 +9,7 @@ defmodule OpenApiSpex.OperationDsl do end end - def operation_def(action, spec1) do + def operation_def(action, spec) do quote do if !Module.get_attribute(__MODULE__, :operation_defined) do Module.register_attribute(__MODULE__, :spec_attributes, accumulate: true) @@ -19,7 +19,11 @@ defmodule OpenApiSpex.OperationDsl do @operation_defined true end - @spec_attributes {unquote(action), unquote(spec1)} + @spec_attributes {unquote(action), operation_spec(unquote(spec))} end end + + def operation_spec(spec) do + struct!(OpenApiSpex.Operation, spec) + end end diff --git a/test/operation_dsl_test.exs b/test/operation_dsl_test.exs index 3073fce1..71dad604 100644 --- a/test/operation_dsl_test.exs +++ b/test/operation_dsl_test.exs @@ -2,12 +2,17 @@ defmodule OpenApiSpex.OperationDslTest do use ExUnit.Case, async: true alias OpenApiSpexTest.DslController - # alias OpenApiSpex.OperationDsl test "operation/1" do - assert DslController.spec_attributes() == [ - {:show, [summary: "Show user"]}, - {:index, [summary: "User index"]} - ] + assert [ + show: %OpenApiSpex.Operation{ + responses: [], + summary: "Show user" + }, + index: %OpenApiSpex.Operation{ + responses: [], + summary: "User index" + } + ] = DslController.spec_attributes() end end diff --git a/test/support/dsl_controller.ex b/test/support/dsl_controller.ex index a613d30a..cd4607a7 100644 --- a/test/support/dsl_controller.ex +++ b/test/support/dsl_controller.ex @@ -5,7 +5,7 @@ defmodule OpenApiSpexTest.DslController do alias OpenApiSpexTest.Schemas - operation(:index, summary: "User index") + operation(:index, summary: "User index", responses: []) def index(conn, _params) do json(conn, %Schemas.UserResponse{ @@ -19,7 +19,7 @@ defmodule OpenApiSpexTest.DslController do }) end - operation(:show, summary: "Show user") + operation(:show, summary: "Show user", responses: []) def show(conn, %{id: id}) do json(conn, %Schemas.UserResponse{ From 5873f2f96417ff6555fc94a0517b9c0fee58e203 Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Mon, 17 Aug 2020 15:18:13 -0700 Subject: [PATCH 04/17] Show that other parts of the Operation can be added using struct syntax --- test/operation_dsl_test.exs | 14 +++++++++++++- test/support/dsl_controller.ex | 16 +++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/test/operation_dsl_test.exs b/test/operation_dsl_test.exs index 71dad604..2d912be5 100644 --- a/test/operation_dsl_test.exs +++ b/test/operation_dsl_test.exs @@ -7,12 +7,24 @@ defmodule OpenApiSpex.OperationDslTest do assert [ show: %OpenApiSpex.Operation{ responses: [], - summary: "Show user" + summary: "Show user", + parameters: show_parameters }, index: %OpenApiSpex.Operation{ responses: [], summary: "User index" } ] = DslController.spec_attributes() + + assert [ + %OpenApiSpex.Parameter{ + description: "User ID", + example: 1001, + in: :path, + name: :id, + required: true, + schema: %OpenApiSpex.Schema{type: :integer} + } + ] = show_parameters end end diff --git a/test/support/dsl_controller.ex b/test/support/dsl_controller.ex index cd4607a7..614fed10 100644 --- a/test/support/dsl_controller.ex +++ b/test/support/dsl_controller.ex @@ -3,6 +3,7 @@ defmodule OpenApiSpexTest.DslController do import OpenApiSpex.OperationDsl + alias OpenApiSpex.Schema alias OpenApiSpexTest.Schemas operation(:index, summary: "User index", responses: []) @@ -19,7 +20,20 @@ defmodule OpenApiSpexTest.DslController do }) end - operation(:show, summary: "Show user", responses: []) + operation(:show, + summary: "Show user", + parameters: [ + %OpenApiSpex.Parameter{ + in: :path, + name: :id, + description: "User ID", + schema: %Schema{type: :integer}, + required: true, + example: 1001 + } + ], + responses: [] + ) def show(conn, %{id: id}) do json(conn, %Schemas.UserResponse{ From 7235d632aca6e768ce652205432733e0670fdbcb Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Mon, 17 Aug 2020 15:27:52 -0700 Subject: [PATCH 05/17] Accept operation tags at controller level --- lib/open_api_spex/operation_dsl.ex | 23 ++++++++++++++++++++--- test/operation_dsl_test.exs | 5 ++++- test/support/dsl_controller.ex | 2 ++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/operation_dsl.ex index 85fe282a..f89ea474 100644 --- a/lib/open_api_spex/operation_dsl.ex +++ b/lib/open_api_spex/operation_dsl.ex @@ -3,9 +3,17 @@ defmodule OpenApiSpex.OperationDsl do operation_def(action, spec) end + defmacro tags(tags) do + tags_def(tags) + end + defmacro before_compile(_env) do quote do def spec_attributes, do: @spec_attributes + + def controller_tags do + Module.get_attribute(__MODULE__, :controller_tags) || [] + end end end @@ -19,11 +27,20 @@ defmodule OpenApiSpex.OperationDsl do @operation_defined true end - @spec_attributes {unquote(action), operation_spec(unquote(spec))} + @spec_attributes {unquote(action), operation_spec(__MODULE__, unquote(spec))} end end - def operation_spec(spec) do - struct!(OpenApiSpex.Operation, spec) + def tags_def(tags) do + quote do + @controller_tags unquote(tags) + end + end + + def operation_spec(module, spec) do + tags = spec[:tags] || Module.get_attribute(module, :controller_tags) + + struct!(OpenApiSpex.Operation, tags: tags, responses: []) + |> struct!(spec) end end diff --git a/test/operation_dsl_test.exs b/test/operation_dsl_test.exs index 2d912be5..b31b8520 100644 --- a/test/operation_dsl_test.exs +++ b/test/operation_dsl_test.exs @@ -8,7 +8,8 @@ defmodule OpenApiSpex.OperationDslTest do show: %OpenApiSpex.Operation{ responses: [], summary: "Show user", - parameters: show_parameters + parameters: show_parameters, + tags: show_tags }, index: %OpenApiSpex.Operation{ responses: [], @@ -16,6 +17,8 @@ defmodule OpenApiSpex.OperationDslTest do } ] = DslController.spec_attributes() + assert show_tags == ["users"] + assert [ %OpenApiSpex.Parameter{ description: "User ID", diff --git a/test/support/dsl_controller.ex b/test/support/dsl_controller.ex index 614fed10..2f635780 100644 --- a/test/support/dsl_controller.ex +++ b/test/support/dsl_controller.ex @@ -6,6 +6,8 @@ defmodule OpenApiSpexTest.DslController do alias OpenApiSpex.Schema alias OpenApiSpexTest.Schemas + tags(["users"]) + operation(:index, summary: "User index", responses: []) def index(conn, _params) do From 1ed1cdbb7e1c74aeedbd4ac6394d695c412f841a Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Mon, 17 Aug 2020 15:39:06 -0700 Subject: [PATCH 06/17] Extract Controller private functions to OperationBuilder --- lib/open_api_spex/controller.ex | 126 ++----------------------- lib/open_api_spex/operation_builder.ex | 119 +++++++++++++++++++++++ 2 files changed, 125 insertions(+), 120 deletions(-) create mode 100644 lib/open_api_spex/operation_builder.ex diff --git a/lib/open_api_spex/controller.ex b/lib/open_api_spex/controller.ex index 40741735..2d02d3bd 100644 --- a/lib/open_api_spex/controller.ex +++ b/lib/open_api_spex/controller.ex @@ -157,7 +157,7 @@ defmodule OpenApiSpex.Controller do ``` ''' - alias OpenApiSpex.{Operation, Parameter, Response, Reference} + alias OpenApiSpex.{Operation, OperationBuilder} defmacro __using__(_opts) do quote do @@ -177,11 +177,11 @@ defmodule OpenApiSpex.Controller do %Operation{ summary: summary, description: description, - operationId: build_operation_id(meta, mod, name), - parameters: build_parameters(meta), - requestBody: build_request_body(meta), - responses: build_responses(meta), - security: build_security(meta), + operationId: OperationBuilder.build_operation_id(meta, mod, name), + parameters: OperationBuilder.build_parameters(meta), + requestBody: OperationBuilder.build_request_body(meta), + responses: OperationBuilder.build_responses(meta), + security: OperationBuilder.build_security(meta), tags: Map.get(mod_meta, :tags, []) ++ Map.get(meta, :tags, []) } else @@ -230,118 +230,4 @@ defmodule OpenApiSpex.Controller do nil end end - - defp ensure_type_and_schema_exclusive!(name, type, schema) do - if type != nil && schema != nil do - raise ArgumentError, - message: """ - Both :type and :schema options were specified for #{inspect(name)}. Please specify only one. - :type is a shortcut for base data types https://swagger.io/docs/specification/data-models/data-types/ - which at the end imports as `%Schema{type: type}`. For more control over schemas please - use @doc parameters: [ - id: [in: :path, schema: MyCustomSchema] - ] - """ - end - end - - defp build_operation_id(meta, mod, name) do - Map.get(meta, :operation_id, "#{inspect(mod)}.#{name}") - end - - defp build_parameters(%{parameters: params}) when is_list(params) or is_map(params) do - params - |> Enum.reduce([], fn - parameter = %Parameter{}, acc -> - [parameter | acc] - - ref = %Reference{}, acc -> - [ref | acc] - - {:"$ref", ref = "#/components/parameters/" <> _name}, acc -> - [%Reference{"$ref": ref} | acc] - - {name, options}, acc -> - {location, options} = Keyword.pop(options, :in, :query) - {type, options} = Keyword.pop(options, :type, nil) - {schema, options} = Keyword.pop(options, :schema, nil) - {description, options} = Keyword.pop(options, :description, "") - - ensure_type_and_schema_exclusive!(name, type, schema) - - schema = type || schema || :string - - [Operation.parameter(name, location, schema, description, options) | acc] - - unsupported, acc -> - IO.warn("Invalid parameters declaration found: " <> inspect(unsupported)) - - acc - end) - |> Enum.reverse() - end - - defp build_parameters(%{parameters: _params}) do - IO.warn(""" - parameters tag should be map or list, for example: - @doc parameters: [ - id: [in: :path, schema: MyCustomSchema] - ] - """) - - [] - end - - defp build_parameters(_), do: [] - - defp build_responses(%{responses: responses}) when is_list(responses) or is_map(responses) do - Map.new(responses, fn - {status, {description, mime, schema}} -> - {Plug.Conn.Status.code(status), Operation.response(description, mime, schema)} - - {status, {description, mime, schema, opts}} -> - {Plug.Conn.Status.code(status), Operation.response(description, mime, schema, opts)} - - {status, %Response{} = response} -> - {Plug.Conn.Status.code(status), response} - - {status, description} when is_binary(description) -> - {Plug.Conn.Status.code(status), %Response{description: description}} - end) - end - - defp build_responses(%{responses: _responses}) do - IO.warn(""" - responses tag should be map or keyword list, for example: - @doc responses: %{ - 200 => {"Response name", "application/json", schema}, - :not_found => {"Response name", "application/json", schema} - } - """) - - [] - end - - defp build_responses(_), do: [] - - defp build_request_body(%{body: {name, mime, schema}}) do - IO.warn("Using :body key for requestBody is deprecated. Please use :request_body instead.") - Operation.request_body(name, mime, schema) - end - - defp build_request_body(%{request_body: {name, mime, schema}}) do - Operation.request_body(name, mime, schema) - end - - defp build_request_body(%{request_body: {name, mime, schema, opts}}) do - Operation.request_body(name, mime, schema, opts) - end - - defp build_request_body(_), do: nil - - defp build_security(%{security: security}) do - security - end - - defp build_security(_), do: nil end diff --git a/lib/open_api_spex/operation_builder.ex b/lib/open_api_spex/operation_builder.ex new file mode 100644 index 00000000..f20b33ab --- /dev/null +++ b/lib/open_api_spex/operation_builder.ex @@ -0,0 +1,119 @@ +defmodule OpenApiSpex.OperationBuilder do + @moduledoc false + + alias OpenApiSpex.{Operation, Parameter, Response, Reference} + + def ensure_type_and_schema_exclusive!(name, type, schema) do + if type != nil && schema != nil do + raise ArgumentError, + message: """ + Both :type and :schema options were specified for #{inspect(name)}. Please specify only one. + :type is a shortcut for base data types https://swagger.io/docs/specification/data-models/data-types/ + which at the end imports as `%Schema{type: type}`. For more control over schemas please + use @doc parameters: [ + id: [in: :path, schema: MyCustomSchema] + ] + """ + end + end + + def build_operation_id(meta, mod, name) do + Map.get(meta, :operation_id, "#{inspect(mod)}.#{name}") + end + + def build_parameters(%{parameters: params}) when is_list(params) or is_map(params) do + params + |> Enum.reduce([], fn + parameter = %Parameter{}, acc -> + [parameter | acc] + + ref = %Reference{}, acc -> + [ref | acc] + + {:"$ref", ref = "#/components/parameters/" <> _name}, acc -> + [%Reference{"$ref": ref} | acc] + + {name, options}, acc -> + {location, options} = Keyword.pop(options, :in, :query) + {type, options} = Keyword.pop(options, :type, nil) + {schema, options} = Keyword.pop(options, :schema, nil) + {description, options} = Keyword.pop(options, :description, "") + + ensure_type_and_schema_exclusive!(name, type, schema) + + schema = type || schema || :string + + [Operation.parameter(name, location, schema, description, options) | acc] + + unsupported, acc -> + IO.warn("Invalid parameters declaration found: " <> inspect(unsupported)) + + acc + end) + |> Enum.reverse() + end + + def build_parameters(%{parameters: _params}) do + IO.warn(""" + parameters tag should be map or list, for example: + @doc parameters: [ + id: [in: :path, schema: MyCustomSchema] + ] + """) + + [] + end + + def build_parameters(_), do: [] + + def build_responses(%{responses: responses}) when is_list(responses) or is_map(responses) do + Map.new(responses, fn + {status, {description, mime, schema}} -> + {Plug.Conn.Status.code(status), Operation.response(description, mime, schema)} + + {status, {description, mime, schema, opts}} -> + {Plug.Conn.Status.code(status), Operation.response(description, mime, schema, opts)} + + {status, %Response{} = response} -> + {Plug.Conn.Status.code(status), response} + + {status, description} when is_binary(description) -> + {Plug.Conn.Status.code(status), %Response{description: description}} + end) + end + + def build_responses(%{responses: _responses}) do + IO.warn(""" + responses tag should be map or keyword list, for example: + @doc responses: %{ + 200 => {"Response name", "application/json", schema}, + :not_found => {"Response name", "application/json", schema} + } + """) + + [] + end + + def build_responses(_), do: [] + + def build_request_body(%{body: {name, mime, schema}}) do + IO.warn("Using :body key for requestBody is deprecated. Please use :request_body instead.") + Operation.request_body(name, mime, schema) + end + + def build_request_body(%{request_body: {name, mime, schema}}) do + Operation.request_body(name, mime, schema) + end + + def build_request_body(%{request_body: {name, mime, schema, opts}}) do + Operation.request_body(name, mime, schema, opts) + end + + def build_request_body(_), do: nil + + def build_security(%{security: security}) do + security + end + + def build_security(_), do: nil +end From 2de9dfc788bfd7ef4cb7378d739ec5eca48e6e0c Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Mon, 17 Aug 2020 15:46:51 -0700 Subject: [PATCH 07/17] Allow shortcut syntax for parameters --- lib/open_api_spex/operation_dsl.ex | 13 ++++++++++++- test/operation_dsl_test.exs | 13 ++++++++++++- test/support/dsl_controller.ex | 19 ++++++++++--------- 3 files changed, 34 insertions(+), 11 deletions(-) diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/operation_dsl.ex index f89ea474..e2c81dc7 100644 --- a/lib/open_api_spex/operation_dsl.ex +++ b/lib/open_api_spex/operation_dsl.ex @@ -1,4 +1,6 @@ defmodule OpenApiSpex.OperationDsl do + alias OpenApiSpex.OperationBuilder + defmacro operation(action, spec) do operation_def(action, spec) end @@ -38,9 +40,18 @@ defmodule OpenApiSpex.OperationDsl do end def operation_spec(module, spec) do + spec = Map.new(spec) tags = spec[:tags] || Module.get_attribute(module, :controller_tags) - struct!(OpenApiSpex.Operation, tags: tags, responses: []) + initial_attrs = [ + tags: tags, + responses: [], + parameters: OperationBuilder.build_parameters(spec) + ] + + spec = Map.delete(spec, :parameters) + + struct!(OpenApiSpex.Operation, initial_attrs) |> struct!(spec) end end diff --git a/test/operation_dsl_test.exs b/test/operation_dsl_test.exs index b31b8520..18422aec 100644 --- a/test/operation_dsl_test.exs +++ b/test/operation_dsl_test.exs @@ -13,7 +13,8 @@ defmodule OpenApiSpex.OperationDslTest do }, index: %OpenApiSpex.Operation{ responses: [], - summary: "User index" + summary: "User index", + parameters: index_parameters } ] = DslController.spec_attributes() @@ -29,5 +30,15 @@ defmodule OpenApiSpex.OperationDslTest do schema: %OpenApiSpex.Schema{type: :integer} } ] = show_parameters + + assert [ + %OpenApiSpex.Parameter{ + description: "Free-form query string", + example: "jane", + in: :query, + name: :query, + schema: %OpenApiSpex.Schema{type: :string} + } + ] = index_parameters end end diff --git a/test/support/dsl_controller.ex b/test/support/dsl_controller.ex index 2f635780..7448a2fe 100644 --- a/test/support/dsl_controller.ex +++ b/test/support/dsl_controller.ex @@ -3,12 +3,16 @@ defmodule OpenApiSpexTest.DslController do import OpenApiSpex.OperationDsl - alias OpenApiSpex.Schema alias OpenApiSpexTest.Schemas tags(["users"]) - operation(:index, summary: "User index", responses: []) + operation(:index, + summary: "User index", + parameters: [ + query: [in: :query, type: :string, description: "Free-form query string", example: "jane"] + ] + ) def index(conn, _params) do json(conn, %Schemas.UserResponse{ @@ -25,16 +29,13 @@ defmodule OpenApiSpexTest.DslController do operation(:show, summary: "Show user", parameters: [ - %OpenApiSpex.Parameter{ + id: [ in: :path, - name: :id, description: "User ID", - schema: %Schema{type: :integer}, - required: true, + type: :integer, example: 1001 - } - ], - responses: [] + ] + ] ) def show(conn, %{id: id}) do From dd7f4ae66e1dd668338b2da535fc9a315982ff4d Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Mon, 17 Aug 2020 16:04:04 -0700 Subject: [PATCH 08/17] Remove extraneous struct usage --- test/support/dsl_controller.ex | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/support/dsl_controller.ex b/test/support/dsl_controller.ex index 7448a2fe..e7a987f6 100644 --- a/test/support/dsl_controller.ex +++ b/test/support/dsl_controller.ex @@ -3,8 +3,6 @@ defmodule OpenApiSpexTest.DslController do import OpenApiSpex.OperationDsl - alias OpenApiSpexTest.Schemas - tags(["users"]) operation(:index, @@ -15,9 +13,9 @@ defmodule OpenApiSpexTest.DslController do ) def index(conn, _params) do - json(conn, %Schemas.UserResponse{ + json(conn, %{ data: [ - %Schemas.User{ + %{ id: "abc123", name: "joe user", email: "joe@gmail.com" @@ -39,8 +37,8 @@ defmodule OpenApiSpexTest.DslController do ) def show(conn, %{id: id}) do - json(conn, %Schemas.UserResponse{ - data: %Schemas.User{ + json(conn, %{ + data: %{ id: id, name: "joe user", email: "joe@gmail.com" From 480963bcd7f014c1f10e4fb1c44cea6294b7303f Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Tue, 25 Aug 2020 07:18:58 -0700 Subject: [PATCH 09/17] Auto-define open_api_operation/1 for each operation spec --- lib/open_api_spex/operation_dsl.ex | 22 ++++++++- test/operation_dsl_test.exs | 72 +++++++++++++++++------------- 2 files changed, 62 insertions(+), 32 deletions(-) diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/operation_dsl.ex index e2c81dc7..3f36bae2 100644 --- a/lib/open_api_spex/operation_dsl.ex +++ b/lib/open_api_spex/operation_dsl.ex @@ -1,17 +1,27 @@ defmodule OpenApiSpex.OperationDsl do alias OpenApiSpex.OperationBuilder + @doc """ + Defines an Operation spec in a controller. + """ defmacro operation(action, spec) do operation_def(action, spec) end + @doc """ + Defines a list of tags that all operations in a controller will share. + """ defmacro tags(tags) do tags_def(tags) end + @doc false defmacro before_compile(_env) do quote do - def spec_attributes, do: @spec_attributes + def open_api_operation(action) do + module_name = __MODULE__ |> to_string() |> String.replace_leading("Elixir.", "") + IO.warn("No operation spec defined for controller action #{module_name}.#{action}") + end def controller_tags do Module.get_attribute(__MODULE__, :controller_tags) || [] @@ -19,6 +29,7 @@ defmodule OpenApiSpex.OperationDsl do end end + @doc false def operation_def(action, spec) do quote do if !Module.get_attribute(__MODULE__, :operation_defined) do @@ -30,15 +41,21 @@ defmodule OpenApiSpex.OperationDsl do end @spec_attributes {unquote(action), operation_spec(__MODULE__, unquote(spec))} + + def open_api_operation(unquote(action)) do + @spec_attributes[unquote(action)] + end end end + @doc false def tags_def(tags) do quote do @controller_tags unquote(tags) end end + @doc false def operation_spec(module, spec) do spec = Map.new(spec) tags = spec[:tags] || Module.get_attribute(module, :controller_tags) @@ -51,7 +68,8 @@ defmodule OpenApiSpex.OperationDsl do spec = Map.delete(spec, :parameters) - struct!(OpenApiSpex.Operation, initial_attrs) + OpenApiSpex.Operation + |> struct!(initial_attrs) |> struct!(spec) end end diff --git a/test/operation_dsl_test.exs b/test/operation_dsl_test.exs index 18422aec..d5c4f7c2 100644 --- a/test/operation_dsl_test.exs +++ b/test/operation_dsl_test.exs @@ -1,44 +1,56 @@ defmodule OpenApiSpex.OperationDslTest do use ExUnit.Case, async: true + import ExUnit.CaptureIO + alias OpenApiSpexTest.DslController - test "operation/1" do - assert [ - show: %OpenApiSpex.Operation{ + describe "operation/1" do + test "defines open_api_operation/1 for :show action" do + assert %OpenApiSpex.Operation{ responses: [], summary: "Show user", parameters: show_parameters, tags: show_tags - }, - index: %OpenApiSpex.Operation{ + } = DslController.open_api_operation(:show) + + assert show_tags == ["users"] + + assert [ + %OpenApiSpex.Parameter{ + description: "User ID", + example: 1001, + in: :path, + name: :id, + required: true, + schema: %OpenApiSpex.Schema{type: :integer} + } + ] = show_parameters + end + + test "defines open_api_operation/1 for :index action" do + assert %OpenApiSpex.Operation{ responses: [], summary: "User index", parameters: index_parameters - } - ] = DslController.spec_attributes() - - assert show_tags == ["users"] - - assert [ - %OpenApiSpex.Parameter{ - description: "User ID", - example: 1001, - in: :path, - name: :id, - required: true, - schema: %OpenApiSpex.Schema{type: :integer} - } - ] = show_parameters - - assert [ - %OpenApiSpex.Parameter{ - description: "Free-form query string", - example: "jane", - in: :query, - name: :query, - schema: %OpenApiSpex.Schema{type: :string} - } - ] = index_parameters + } = DslController.open_api_operation(:index) + + assert [ + %OpenApiSpex.Parameter{ + description: "Free-form query string", + example: "jane", + in: :query, + name: :query, + schema: %OpenApiSpex.Schema{type: :string} + } + ] = index_parameters + end + + test "outputs warning when action not defined for called open_api_operation" do + output = capture_io(:stderr, fn -> DslController.open_api_operation(:undefined) end) + + assert output =~ + ~r/warning:.*No operation spec defined for controller action OpenApiSpexTest.DslController.undefined/ + end end end From e97f46787b88cb1ce9a23df0dc10325c6be28acf Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Tue, 25 Aug 2020 07:39:07 -0700 Subject: [PATCH 10/17] Make parens optional for `operation/2` and `tags/1` --- .formatter.exs | 10 +++++++++- lib/open_api_spex/operation_dsl.ex | 6 ++++++ test/support/dsl_controller.ex | 8 +++----- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index c2a48411..a24b82ab 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -5,6 +5,14 @@ transport: 2, action_fallback: 1, socket: 2, - render: 2 + render: 2, + operation: 2, + tags: 1 + ], + export: [ + locals_without_parens: [ + operation: 2, + tags: 1 + ] ] ] diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/operation_dsl.ex index 3f36bae2..fbd0e0ce 100644 --- a/lib/open_api_spex/operation_dsl.ex +++ b/lib/open_api_spex/operation_dsl.ex @@ -1,4 +1,10 @@ defmodule OpenApiSpex.OperationDsl do + @moduledoc """ + Macros for defining operation specs and operation tags in a Phoenix controller. + + If you use Elixir Formatter, be sure to add `:open_api_spex` to the `:import_deps` + list in the `.formatter.exs` file of your project. + """ alias OpenApiSpex.OperationBuilder @doc """ diff --git a/test/support/dsl_controller.ex b/test/support/dsl_controller.ex index e7a987f6..014c026e 100644 --- a/test/support/dsl_controller.ex +++ b/test/support/dsl_controller.ex @@ -3,14 +3,13 @@ defmodule OpenApiSpexTest.DslController do import OpenApiSpex.OperationDsl - tags(["users"]) + tags ["users"] - operation(:index, + operation :index, summary: "User index", parameters: [ query: [in: :query, type: :string, description: "Free-form query string", example: "jane"] ] - ) def index(conn, _params) do json(conn, %{ @@ -24,7 +23,7 @@ defmodule OpenApiSpexTest.DslController do }) end - operation(:show, + operation :show, summary: "Show user", parameters: [ id: [ @@ -34,7 +33,6 @@ defmodule OpenApiSpexTest.DslController do example: 1001 ] ] - ) def show(conn, %{id: id}) do json(conn, %{ From f82fdab36ed0d52fc27854552683b54b6a794ef5 Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Wed, 26 Aug 2020 19:42:47 -0700 Subject: [PATCH 11/17] Support responses, requestBody, security, operationId --- lib/open_api_spex/controller.ex | 2 +- lib/open_api_spex/operation_builder.ex | 4 ++ lib/open_api_spex/operation_dsl.ex | 30 +++++++------- test/operation_dsl_test.exs | 57 ++++++++++++++++---------- test/support/dsl_controller.ex | 49 ++++++++++++++-------- 5 files changed, 86 insertions(+), 56 deletions(-) diff --git a/lib/open_api_spex/controller.ex b/lib/open_api_spex/controller.ex index 2d02d3bd..cd0a41f9 100644 --- a/lib/open_api_spex/controller.ex +++ b/lib/open_api_spex/controller.ex @@ -182,7 +182,7 @@ defmodule OpenApiSpex.Controller do requestBody: OperationBuilder.build_request_body(meta), responses: OperationBuilder.build_responses(meta), security: OperationBuilder.build_security(meta), - tags: Map.get(mod_meta, :tags, []) ++ Map.get(meta, :tags, []) + tags: OperationBuilder.build_tags(meta, mod_meta) } else _ -> nil diff --git a/lib/open_api_spex/operation_builder.ex b/lib/open_api_spex/operation_builder.ex index f20b33ab..256cea01 100644 --- a/lib/open_api_spex/operation_builder.ex +++ b/lib/open_api_spex/operation_builder.ex @@ -116,4 +116,8 @@ defmodule OpenApiSpex.OperationBuilder do end def build_security(_), do: nil + + def build_tags(operation_spec, module_spec) do + Map.get(module_spec, :tags, []) ++ Map.get(operation_spec, :tags, []) + end end diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/operation_dsl.ex index fbd0e0ce..9ecfd171 100644 --- a/lib/open_api_spex/operation_dsl.ex +++ b/lib/open_api_spex/operation_dsl.ex @@ -5,7 +5,7 @@ defmodule OpenApiSpex.OperationDsl do If you use Elixir Formatter, be sure to add `:open_api_spex` to the `:import_deps` list in the `.formatter.exs` file of your project. """ - alias OpenApiSpex.OperationBuilder + alias OpenApiSpex.{Operation, OperationBuilder} @doc """ Defines an Operation spec in a controller. @@ -46,7 +46,8 @@ defmodule OpenApiSpex.OperationDsl do @operation_defined true end - @spec_attributes {unquote(action), operation_spec(__MODULE__, unquote(spec))} + @spec_attributes {unquote(action), + operation_spec(__MODULE__, unquote(action), unquote(spec))} def open_api_operation(unquote(action)) do @spec_attributes[unquote(action)] @@ -62,20 +63,19 @@ defmodule OpenApiSpex.OperationDsl do end @doc false - def operation_spec(module, spec) do + def operation_spec(module, action, spec) do spec = Map.new(spec) - tags = spec[:tags] || Module.get_attribute(module, :controller_tags) + controller_tags = Module.get_attribute(module, :controller_tags) || [] - initial_attrs = [ - tags: tags, - responses: [], - parameters: OperationBuilder.build_parameters(spec) - ] - - spec = Map.delete(spec, :parameters) - - OpenApiSpex.Operation - |> struct!(initial_attrs) - |> struct!(spec) + %Operation{ + description: Map.get(spec, :description), + operationId: OperationBuilder.build_operation_id(spec, module, action), + parameters: OperationBuilder.build_parameters(spec), + requestBody: OperationBuilder.build_request_body(spec), + responses: OperationBuilder.build_responses(spec), + security: OperationBuilder.build_security(spec), + summary: Map.get(spec, :summary), + tags: OperationBuilder.build_tags(spec, %{tags: controller_tags}) + } end end diff --git a/test/operation_dsl_test.exs b/test/operation_dsl_test.exs index d5c4f7c2..00dfd67a 100644 --- a/test/operation_dsl_test.exs +++ b/test/operation_dsl_test.exs @@ -3,18 +3,19 @@ defmodule OpenApiSpex.OperationDslTest do import ExUnit.CaptureIO + alias OpenApiSpex.{MediaType, RequestBody, Response} alias OpenApiSpexTest.DslController describe "operation/1" do - test "defines open_api_operation/1 for :show action" do + test "supports :parameters" do assert %OpenApiSpex.Operation{ - responses: [], - summary: "Show user", - parameters: show_parameters, - tags: show_tags - } = DslController.open_api_operation(:show) + responses: %{}, + summary: "Update user", + parameters: update_parameters, + tags: update_tags + } = DslController.open_api_operation(:update) - assert show_tags == ["users"] + assert update_tags == ["users"] assert [ %OpenApiSpex.Parameter{ @@ -25,25 +26,37 @@ defmodule OpenApiSpex.OperationDslTest do required: true, schema: %OpenApiSpex.Schema{type: :integer} } - ] = show_parameters + ] = update_parameters end - test "defines open_api_operation/1 for :index action" do + test ":request_body" do assert %OpenApiSpex.Operation{ - responses: [], - summary: "User index", - parameters: index_parameters - } = DslController.open_api_operation(:index) + summary: "Update user", + requestBody: request_body + } = DslController.open_api_operation(:update) - assert [ - %OpenApiSpex.Parameter{ - description: "Free-form query string", - example: "jane", - in: :query, - name: :query, - schema: %OpenApiSpex.Schema{type: :string} - } - ] = index_parameters + assert %RequestBody{ + content: %{"application/json" => media_type}, + description: "User params" + } = request_body + + assert %MediaType{schema: OpenApiSpexTest.DslController.UserParams} = media_type + end + + test ":responses" do + assert %OpenApiSpex.Operation{ + summary: "Update user", + responses: responses + } = DslController.open_api_operation(:update) + + assert %{200 => response} = responses + + assert %Response{ + content: %{"application/json" => media_type}, + description: "User response" + } = response + + assert %MediaType{schema: OpenApiSpexTest.DslController.UserResponse} = media_type end test "outputs warning when action not defined for called open_api_operation" do diff --git a/test/support/dsl_controller.ex b/test/support/dsl_controller.ex index 014c026e..7ad2beba 100644 --- a/test/support/dsl_controller.ex +++ b/test/support/dsl_controller.ex @@ -3,28 +3,37 @@ defmodule OpenApiSpexTest.DslController do import OpenApiSpex.OperationDsl - tags ["users"] + defmodule UserParams do + alias OpenApiSpex.Schema + require OpenApiSpex - operation :index, - summary: "User index", - parameters: [ - query: [in: :query, type: :string, description: "Free-form query string", example: "jane"] - ] + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + email: %Schema{type: :string}, + name: %Schema{type: :string} + } + }) + end - def index(conn, _params) do - json(conn, %{ - data: [ - %{ - id: "abc123", - name: "joe user", - email: "joe@gmail.com" - } - ] + defmodule UserResponse do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + email: %Schema{type: :string}, + name: %Schema{type: :string} + } }) end - operation :show, - summary: "Show user", + tags ["users"] + + operation :update, + summary: "Update user", parameters: [ id: [ in: :path, @@ -32,9 +41,13 @@ defmodule OpenApiSpexTest.DslController do type: :integer, example: 1001 ] + ], + request_body: {"User params", "application/json", UserParams}, + responses: [ + ok: {"User response", "application/json", UserResponse} ] - def show(conn, %{id: id}) do + def update(conn, %{"id" => id}) do json(conn, %{ data: %{ id: id, From 4ab35d0b4284f48be755f41751707c4069a5026f Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Fri, 4 Sep 2020 08:04:52 -0700 Subject: [PATCH 12/17] Clean up code. Add code docs. --- lib/open_api_spex/operation_dsl.ex | 89 ++++++++++++++++++++---------- 1 file changed, 59 insertions(+), 30 deletions(-) diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/operation_dsl.ex index 9ecfd171..75290945 100644 --- a/lib/open_api_spex/operation_dsl.ex +++ b/lib/open_api_spex/operation_dsl.ex @@ -2,8 +2,47 @@ defmodule OpenApiSpex.OperationDsl do @moduledoc """ Macros for defining operation specs and operation tags in a Phoenix controller. - If you use Elixir Formatter, be sure to add `:open_api_spex` to the `:import_deps` - list in the `.formatter.exs` file of your project. + If you use Elixir Formatter, `:open_api_spex` can be added to the `:import_deps` + list in the `.formatter.exs` file of your project to make parentheses of the + macros optional. + + ## Example + + defmodule MyAppWeb.DslController do + use Phoenix.Controller + + import OpenApiSpex.OperationDsl + + alias MyAppWeb.Schemas.{UserParams, UserResponse} + + tags ["users"] + + operation :update, + summary: "Update user", + parameters: [ + id: [ + in: :path, + description: "User ID", + type: :integer, + example: 1001 + ] + ], + request_body: {"User params", "application/json", UserParams}, + responses: [ + ok: {"User response", "application/json", UserResponse} + ] + + def update(conn, %{"id" => id}) do + json(conn, %{ + data: %{ + id: id, + name: "joe user", + email: "joe@gmail.com" + } + }) + end + end + """ alias OpenApiSpex.{Operation, OperationBuilder} @@ -11,32 +50,6 @@ defmodule OpenApiSpex.OperationDsl do Defines an Operation spec in a controller. """ defmacro operation(action, spec) do - operation_def(action, spec) - end - - @doc """ - Defines a list of tags that all operations in a controller will share. - """ - defmacro tags(tags) do - tags_def(tags) - end - - @doc false - defmacro before_compile(_env) do - quote do - def open_api_operation(action) do - module_name = __MODULE__ |> to_string() |> String.replace_leading("Elixir.", "") - IO.warn("No operation spec defined for controller action #{module_name}.#{action}") - end - - def controller_tags do - Module.get_attribute(__MODULE__, :controller_tags) || [] - end - end - end - - @doc false - def operation_def(action, spec) do quote do if !Module.get_attribute(__MODULE__, :operation_defined) do Module.register_attribute(__MODULE__, :spec_attributes, accumulate: true) @@ -55,13 +68,29 @@ defmodule OpenApiSpex.OperationDsl do end end - @doc false - def tags_def(tags) do + @doc """ + Defines a list of tags that all operations in a controller will share. + """ + defmacro tags(tags) do quote do @controller_tags unquote(tags) end end + @doc false + defmacro before_compile(_env) do + quote do + def open_api_operation(action) do + module_name = __MODULE__ |> to_string() |> String.replace_leading("Elixir.", "") + IO.warn("No operation spec defined for controller action #{module_name}.#{action}") + end + + def controller_tags do + Module.get_attribute(__MODULE__, :controller_tags) || [] + end + end + end + @doc false def operation_spec(module, action, spec) do spec = Map.new(spec) From 5e795a206439c4ad85003fda9f43848807c12446 Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Fri, 4 Sep 2020 21:29:45 -0700 Subject: [PATCH 13/17] Documentation --- README.md | 15 +++ lib/open_api_spex/operation_dsl.ex | 145 ++++++++++++++++++++++++++++- 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index abdf788b..d2561fb7 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,10 @@ For each plug (controller) that will handle API requests, operations need to be defined that the plug/controller will handle. The operations can be defined using moduledoc attributes that are supported in Elixir 1.7 and higher. +Note: For projects using Elixir releases, [#242](there is an issue) that +potentially breaks OpenApiSpex's integration with your application. See the next section +for work-arounds to this issue. + ```elixir defmodule MyAppWeb.UserController do @moduledoc tags: ["users"] @@ -164,6 +168,10 @@ The definitions data is cached, so it does not actually extract the definitions Note that in the ExDoc-based definitions, the names of the OpenAPI fields follow `snake_case` naming convention instead of OpenAPI's (and JSON Schema's) `camelCase` convention. +### Alternatives to ExDoc-Based Operation Specs + +#### %Operation{} + If the ExDoc-based operation specs don't provide the flexibiliy you need, the `%Operation{}` struct and related structs can be used instead. See the [example user controller that uses `%Operation{}` structs]([example web app](https://github.com/open-api-spex/open_api_spex/blob/master/examples/phoenix_app/lib/phoenix_app_web/controllers/user_controller_with_struct_specs.ex).) @@ -171,6 +179,13 @@ and related structs can be used instead. See the For examples of other action operations, see the [example web app](https://github.com/open-api-spex/open_api_spex/blob/master/examples/phoenix_app/lib/phoenix_app_web/controllers/user_controller.ex). +#### Experimental API + +There is a new, experimental Operation spec API that has the same lightweight syntax +as the ExDoc-based API, but without the potentially breaking issue described in +[issue #242](https://github.com/open-api-spex/open_api_spex/issues/242). +This new API is described in the `OpenApiSpex.OperationDsl` docs. + ### Schemas Next, declare JSON schema modules for the request and response bodies. diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/operation_dsl.ex index 75290945..c4d2ecc4 100644 --- a/lib/open_api_spex/operation_dsl.ex +++ b/lib/open_api_spex/operation_dsl.ex @@ -2,12 +2,10 @@ defmodule OpenApiSpex.OperationDsl do @moduledoc """ Macros for defining operation specs and operation tags in a Phoenix controller. - If you use Elixir Formatter, `:open_api_spex` can be added to the `:import_deps` - list in the `.formatter.exs` file of your project to make parentheses of the - macros optional. - ## Example + Here is an example Phoenix controller that uses the OperationDsl Operation specs: + defmodule MyAppWeb.DslController do use Phoenix.Controller @@ -43,12 +41,142 @@ defmodule OpenApiSpex.OperationDsl do end end + If you use Elixir Formatter, `:open_api_spex` can be added to the `:import_deps` + list in the `.formatter.exs` file of your project to make parentheses of the + macros optional. """ alias OpenApiSpex.{Operation, OperationBuilder} @doc """ Defines an Operation spec in a controller. + + ## Example + + operation :update, + summary: "Update user", + parameters: [ + id: [ + in: :path, + description: "User ID", + type: :integer, + example: 1001 + ] + ], + request_body: {"User params", "application/json", UserParams}, + responses: [ + ok: {"User response", "application/json", UserResponse} + ] + + ## Options + + These options correlate to the + [Operation fields specified in the Open API spec](https://swagger.io/specification/#operation-object). + One difference however, is that the fields defined in Open API use `camelCase` naming, + while the fields used in `OperationDsl` use `snake_case` to match Elixir's convention. + + - `summary` The operation summary + - `parameters` The endpoint's parameters. The syntax for `parameters` can take multiple forms: + - The common form is a keyword list, where each key is the parameter name, and the value + is a keyword list of options that correlate to the fields in an + [Open API Parameter Object](https://swagger.io/specification/#parameter-object). + For example: + + ```elixir + parameters: [ + id: [ + in: :path, + description: "User ID", + type: :integer, + example: 1001 + ] + ] + ``` + - A `parameters` list can also contain references to parameter schemas. There are two + ways to do that: + + ```elixir + parameters: [ + "$ref": "#/components/parameters/user_id" + # or + %OpenApiSpex.Reference{"$ref": "#/components/parameters/user_id"} + ] + ``` + - `request_body` The endpoint's request body. There are multiple ways to specifiy a request body: + - A three or four-element tuple: + + ```elixir + request_body: { + "User update request body", + "application/json", + UserUpdateRequest, + required: true + } + ``` + + The tuple consists of the following: + 1. The description + 2. The content-type + 3. An Open API schema. This can be a schema module that implements the + `OpenApiSpex.Schema` [behaviour](https://hexdocs.pm/elixir/Module.html#module-behaviour), + or an `OpenApiSpex.Schema` struct. + 4. A optional keyword list of options. There is only one option available, + and that is `required: boolean`. + - `responses` The endpoint's responses, for each HTTP status code the endpoint may respond with. + Multiple syntaxes are supported: + - A common syntax is a keyword list, where each key is the textual name of an HTTP status code. + For example: + + ```elixir + [ + ok: {"User response", "application/json", User}, + not_found: {"User not found", "application/json", NotFound} + ] + ``` + + The list of names and their code mappings is defined in + `Plug.Conn.Status.code/1`. + + - If a map is used, the keys can either be the same atom keys used + in the keyword syntax (`%{ok: ...}`), or they can be integers representing + the HTTP status code directly: + + ```elixir + responses: %{ + 200 => {"User response", "application/json", User}, + 404 => {"User not found", "application/json", NotFound} + } + ``` + + - Each response can be a three-element tuple: + + ```elixir + responses: [ + ok: {"User response", "application/json", User} + # Or + ok: {"User response", "application/json", %OpenApiSpex.Schema{...}} + ] + ``` + + This tuple consists of: + 1. A Description string + 2. A content-type string + 3. An Open API schema. This can be a schema module that implements the + `OpenApiSpex.Schema` [behaviour](https://hexdocs.pm/elixir/Module.html#module-behaviour), + or an `OpenApiSpex.Schema` struct. + + - If the response represents an empty response, the definition value can + be a single string representing the response description. For example: + + ```elixir + responses: [ + no_content: "Empty response" + ] + ``` + + - A response can also be defined as an `OpenApiSpex.RequestBody` struct. In fact, all + response body syntaxes resolve to this struct. """ + @spec operation(action :: atom, spec :: map) :: any defmacro operation(action, spec) do quote do if !Module.get_attribute(__MODULE__, :operation_defined) do @@ -70,7 +198,16 @@ defmodule OpenApiSpex.OperationDsl do @doc """ Defines a list of tags that all operations in a controller will share. + + ## Example + + tags ["users"] + + All operations defined in the controller will inherit the tags specified + by this call. If an operation defines its own tags, the tags in this call + will be appended to the operation's tags. """ + @spec tags(tags :: [String.t()]) :: any defmacro tags(tags) do quote do @controller_tags unquote(tags) From c7df330fd630040ad3b4ac5765ce31b3a5d14bb3 Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Fri, 4 Sep 2020 22:10:17 -0700 Subject: [PATCH 14/17] Add `security/1` macro for controller-wide security requirements --- .formatter.exs | 6 ++-- lib/open_api_spex/controller.ex | 2 +- lib/open_api_spex/operation_builder.ex | 6 ++-- lib/open_api_spex/operation_dsl.ex | 40 +++++++++++++++++++++----- test/operation_dsl_test.exs | 21 +++++++++++--- test/support/dsl_controller.ex | 6 +++- 6 files changed, 62 insertions(+), 19 deletions(-) diff --git a/.formatter.exs b/.formatter.exs index a24b82ab..6774a128 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -7,12 +7,14 @@ socket: 2, render: 2, operation: 2, - tags: 1 + tags: 1, + security: 1 ], export: [ locals_without_parens: [ operation: 2, - tags: 1 + tags: 1, + security: 1 ] ] ] diff --git a/lib/open_api_spex/controller.ex b/lib/open_api_spex/controller.ex index cd0a41f9..75103b95 100644 --- a/lib/open_api_spex/controller.ex +++ b/lib/open_api_spex/controller.ex @@ -181,7 +181,7 @@ defmodule OpenApiSpex.Controller do parameters: OperationBuilder.build_parameters(meta), requestBody: OperationBuilder.build_request_body(meta), responses: OperationBuilder.build_responses(meta), - security: OperationBuilder.build_security(meta), + security: OperationBuilder.build_security(meta, mod_meta), tags: OperationBuilder.build_tags(meta, mod_meta) } else diff --git a/lib/open_api_spex/operation_builder.ex b/lib/open_api_spex/operation_builder.ex index 256cea01..acdd04c4 100644 --- a/lib/open_api_spex/operation_builder.ex +++ b/lib/open_api_spex/operation_builder.ex @@ -111,12 +111,10 @@ defmodule OpenApiSpex.OperationBuilder do def build_request_body(_), do: nil - def build_security(%{security: security}) do - security + def build_security(operation_spec, module_spec) do + Map.get(module_spec, :security, []) ++ Map.get(operation_spec, :security, []) end - def build_security(_), do: nil - def build_tags(operation_spec, module_spec) do Map.get(module_spec, :tags, []) ++ Map.get(operation_spec, :tags, []) end diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/operation_dsl.ex index c4d2ecc4..ea4754c8 100644 --- a/lib/open_api_spex/operation_dsl.ex +++ b/lib/open_api_spex/operation_dsl.ex @@ -14,6 +14,7 @@ defmodule OpenApiSpex.OperationDsl do alias MyAppWeb.Schemas.{UserParams, UserResponse} tags ["users"] + security [%{}, %{"petstore_auth" => ["write:pets", "read:pets"]}] operation :update, summary: "Update user", @@ -54,6 +55,7 @@ defmodule OpenApiSpex.OperationDsl do operation :update, summary: "Update user", + description: "Updates a user record from the given ID path paramter and request body parameters.", parameters: [ id: [ in: :path, @@ -65,7 +67,9 @@ defmodule OpenApiSpex.OperationDsl do request_body: {"User params", "application/json", UserParams}, responses: [ ok: {"User response", "application/json", UserResponse} - ] + ], + security: [%{}, %{"petstore_auth" => ["write:pets", "read:pets"]}], + tags: ["users"] ## Options @@ -175,6 +179,9 @@ defmodule OpenApiSpex.OperationDsl do - A response can also be defined as an `OpenApiSpex.RequestBody` struct. In fact, all response body syntaxes resolve to this struct. + + - Additional fields: There are other Operation fields that can be specified that are not + described here. See `OpenApiSpex.Operation` for all the fields. """ @spec operation(action :: atom, spec :: map) :: any defmacro operation(action, spec) do @@ -210,7 +217,21 @@ defmodule OpenApiSpex.OperationDsl do @spec tags(tags :: [String.t()]) :: any defmacro tags(tags) do quote do - @controller_tags unquote(tags) + @shared_tags unquote(tags) + end + end + + @doc """ + Defines security requirements shared by all operations defined in a controller. + + Security requirements are defined in the form `[%{required(String.t()) => [String.t()]}]`. + See [Security Requirement Object spec](https://swagger.io/specification/#securityRequirementObject) + and `OpenApiSpex.SecurityRequirement` for more information. + """ + @spec security([%{required(String.t()) => [String.t()]}]) :: any + defmacro security(requirements) do + quote do + @shared_security unquote(requirements) end end @@ -222,8 +243,12 @@ defmodule OpenApiSpex.OperationDsl do IO.warn("No operation spec defined for controller action #{module_name}.#{action}") end - def controller_tags do - Module.get_attribute(__MODULE__, :controller_tags) || [] + def shared_tags do + Module.get_attribute(__MODULE__, :shared_tags) || [] + end + + def shared_security do + Module.get_attribute(__MODULE__, :shared_security) || [] end end end @@ -231,7 +256,8 @@ defmodule OpenApiSpex.OperationDsl do @doc false def operation_spec(module, action, spec) do spec = Map.new(spec) - controller_tags = Module.get_attribute(module, :controller_tags) || [] + shared_tags = Module.get_attribute(module, :shared_tags) || [] + security = Module.get_attribute(module, :shared_security) || [] %Operation{ description: Map.get(spec, :description), @@ -239,9 +265,9 @@ defmodule OpenApiSpex.OperationDsl do parameters: OperationBuilder.build_parameters(spec), requestBody: OperationBuilder.build_request_body(spec), responses: OperationBuilder.build_responses(spec), - security: OperationBuilder.build_security(spec), + security: OperationBuilder.build_security(spec, %{security: security}), summary: Map.get(spec, :summary), - tags: OperationBuilder.build_tags(spec, %{tags: controller_tags}) + tags: OperationBuilder.build_tags(spec, %{tags: shared_tags}) } end end diff --git a/test/operation_dsl_test.exs b/test/operation_dsl_test.exs index 00dfd67a..58b2f94e 100644 --- a/test/operation_dsl_test.exs +++ b/test/operation_dsl_test.exs @@ -11,12 +11,9 @@ defmodule OpenApiSpex.OperationDslTest do assert %OpenApiSpex.Operation{ responses: %{}, summary: "Update user", - parameters: update_parameters, - tags: update_tags + parameters: update_parameters } = DslController.open_api_operation(:update) - assert update_tags == ["users"] - assert [ %OpenApiSpex.Parameter{ description: "User ID", @@ -65,5 +62,21 @@ defmodule OpenApiSpex.OperationDslTest do assert output =~ ~r/warning:.*No operation spec defined for controller action OpenApiSpexTest.DslController.undefined/ end + + test "merging shared a op-specific tags" do + assert %OpenApiSpex.Operation{ + tags: tags + } = DslController.open_api_operation(:update) + + assert tags == ["users", "custom"] + end + + test "merging shared a op-specific security" do + assert %OpenApiSpex.Operation{ + security: security + } = DslController.open_api_operation(:update) + + assert security == [%{"api_key" => ["mySecurityScheme"]}, %{"two" => ["another"]}] + end end end diff --git a/test/support/dsl_controller.ex b/test/support/dsl_controller.ex index 7ad2beba..007f319f 100644 --- a/test/support/dsl_controller.ex +++ b/test/support/dsl_controller.ex @@ -32,6 +32,8 @@ defmodule OpenApiSpexTest.DslController do tags ["users"] + security [%{"api_key" => ["mySecurityScheme"]}] + operation :update, summary: "Update user", parameters: [ @@ -45,7 +47,9 @@ defmodule OpenApiSpexTest.DslController do request_body: {"User params", "application/json", UserParams}, responses: [ ok: {"User response", "application/json", UserResponse} - ] + ], + tags: ["custom"], + security: [%{"two" => ["another"]}] def update(conn, %{"id" => id}) do json(conn, %{ From 763e2e7dd6f9c54c4c1abd2d15a1f97e0d1ba48e Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Sat, 5 Sep 2020 08:52:07 -0700 Subject: [PATCH 15/17] Switch to using `use` instead of `import` I wasn't confident that `import` was going to work without any future problems. --- lib/open_api_spex/operation_dsl.ex | 31 ++++++++++++------------- test/operation_dsl_test.exs | 5 ++++ test/support/dsl_controller.ex | 37 ++++++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/operation_dsl.ex index ea4754c8..fbb05cb7 100644 --- a/lib/open_api_spex/operation_dsl.ex +++ b/lib/open_api_spex/operation_dsl.ex @@ -8,8 +8,7 @@ defmodule OpenApiSpex.OperationDsl do defmodule MyAppWeb.DslController do use Phoenix.Controller - - import OpenApiSpex.OperationDsl + use OpenApiSpex.OperationDsl alias MyAppWeb.Schemas.{UserParams, UserResponse} @@ -48,6 +47,18 @@ defmodule OpenApiSpex.OperationDsl do """ alias OpenApiSpex.{Operation, OperationBuilder} + defmacro __using__(_opts) do + quote do + import OpenApiSpex.OperationDsl + + Module.register_attribute(__MODULE__, :spec_attributes, accumulate: true) + + @before_compile {OpenApiSpex.OperationDsl, :before_compile} + + @spec open_api_operation(atom()) :: OpenApiSpex.Operation.t() + end + end + @doc """ Defines an Operation spec in a controller. @@ -186,14 +197,6 @@ defmodule OpenApiSpex.OperationDsl do @spec operation(action :: atom, spec :: map) :: any defmacro operation(action, spec) do quote do - if !Module.get_attribute(__MODULE__, :operation_defined) do - Module.register_attribute(__MODULE__, :spec_attributes, accumulate: true) - - @before_compile {OpenApiSpex.OperationDsl, :before_compile} - - @operation_defined true - end - @spec_attributes {unquote(action), operation_spec(__MODULE__, unquote(action), unquote(spec))} @@ -243,13 +246,9 @@ defmodule OpenApiSpex.OperationDsl do IO.warn("No operation spec defined for controller action #{module_name}.#{action}") end - def shared_tags do - Module.get_attribute(__MODULE__, :shared_tags) || [] - end + def shared_tags, do: @shared_tags - def shared_security do - Module.get_attribute(__MODULE__, :shared_security) || [] - end + def shared_security, do: @shared_security end end diff --git a/test/operation_dsl_test.exs b/test/operation_dsl_test.exs index 58b2f94e..277f1665 100644 --- a/test/operation_dsl_test.exs +++ b/test/operation_dsl_test.exs @@ -78,5 +78,10 @@ defmodule OpenApiSpex.OperationDslTest do assert security == [%{"api_key" => ["mySecurityScheme"]}, %{"two" => ["another"]}] end + + test "second operation is defined" do + assert %OpenApiSpex.Operation{operationId: "OpenApiSpexTest.DslController.index"} = + DslController.open_api_operation(:index) + end end end diff --git a/test/support/dsl_controller.ex b/test/support/dsl_controller.ex index 007f319f..4c24400b 100644 --- a/test/support/dsl_controller.ex +++ b/test/support/dsl_controller.ex @@ -1,7 +1,6 @@ defmodule OpenApiSpexTest.DslController do use Phoenix.Controller - - import OpenApiSpex.OperationDsl + use OpenApiSpex.OperationDsl defmodule UserParams do alias OpenApiSpex.Schema @@ -30,6 +29,23 @@ defmodule OpenApiSpexTest.DslController do }) end + defmodule UsersIndexResponse do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :array, + items: %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + email: %Schema{type: :string}, + name: %Schema{type: :string} + } + } + }) + end + tags ["users"] security [%{"api_key" => ["mySecurityScheme"]}] @@ -60,4 +76,21 @@ defmodule OpenApiSpexTest.DslController do } }) end + + operation :index, + summary: "Users index", + parameters: [ + username: [ + in: :query, + description: "Filter by username", + type: :string + ] + ], + responses: [ + ok: {"Users index response", "application/json", UsersIndexResponse} + ] + + def index(conn, _) do + json(conn, []) + end end From b0a8ec2bf0690b0b6773cd5458606f1e89d37565 Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Sat, 5 Sep 2020 08:58:00 -0700 Subject: [PATCH 16/17] Rename module to ControllerSpecs --- README.md | 2 +- .../{operation_dsl.ex => controller_specs.ex} | 15 +++++++-------- ...ion_dsl_test.exs => controller_specs_test.exs} | 2 +- test/support/dsl_controller.ex | 2 +- 4 files changed, 10 insertions(+), 11 deletions(-) rename lib/open_api_spex/{operation_dsl.ex => controller_specs.ex} (94%) rename test/{operation_dsl_test.exs => controller_specs_test.exs} (98%) diff --git a/README.md b/README.md index d2561fb7..c0341078 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,7 @@ For examples of other action operations, see the There is a new, experimental Operation spec API that has the same lightweight syntax as the ExDoc-based API, but without the potentially breaking issue described in [issue #242](https://github.com/open-api-spex/open_api_spex/issues/242). -This new API is described in the `OpenApiSpex.OperationDsl` docs. +This new API is described in the `OpenApiSpex.ControllerSpecs` docs. ### Schemas diff --git a/lib/open_api_spex/operation_dsl.ex b/lib/open_api_spex/controller_specs.ex similarity index 94% rename from lib/open_api_spex/operation_dsl.ex rename to lib/open_api_spex/controller_specs.ex index fbb05cb7..d66015a4 100644 --- a/lib/open_api_spex/operation_dsl.ex +++ b/lib/open_api_spex/controller_specs.ex @@ -1,19 +1,19 @@ -defmodule OpenApiSpex.OperationDsl do +defmodule OpenApiSpex.ControllerSpecs do @moduledoc """ Macros for defining operation specs and operation tags in a Phoenix controller. ## Example - Here is an example Phoenix controller that uses the OperationDsl Operation specs: + Here is an example Phoenix controller that uses the ControllerSpecs Operation specs: defmodule MyAppWeb.DslController do use Phoenix.Controller - use OpenApiSpex.OperationDsl + use OpenApiSpex.ControllerSpecs alias MyAppWeb.Schemas.{UserParams, UserResponse} tags ["users"] - security [%{}, %{"petstore_auth" => ["write:pets", "read:pets"]}] + security [%{}, %{"petstore_auth" => ["write:users", "read:users"]}] operation :update, summary: "Update user", @@ -49,11 +49,11 @@ defmodule OpenApiSpex.OperationDsl do defmacro __using__(_opts) do quote do - import OpenApiSpex.OperationDsl + import OpenApiSpex.ControllerSpecs Module.register_attribute(__MODULE__, :spec_attributes, accumulate: true) - @before_compile {OpenApiSpex.OperationDsl, :before_compile} + @before_compile {OpenApiSpex.ControllerSpecs, :before_compile} @spec open_api_operation(atom()) :: OpenApiSpex.Operation.t() end @@ -87,7 +87,7 @@ defmodule OpenApiSpex.OperationDsl do These options correlate to the [Operation fields specified in the Open API spec](https://swagger.io/specification/#operation-object). One difference however, is that the fields defined in Open API use `camelCase` naming, - while the fields used in `OperationDsl` use `snake_case` to match Elixir's convention. + while the fields used in `ControllerSpecs` use `snake_case` to match Elixir's convention. - `summary` The operation summary - `parameters` The endpoint's parameters. The syntax for `parameters` can take multiple forms: @@ -227,7 +227,6 @@ defmodule OpenApiSpex.OperationDsl do @doc """ Defines security requirements shared by all operations defined in a controller. - Security requirements are defined in the form `[%{required(String.t()) => [String.t()]}]`. See [Security Requirement Object spec](https://swagger.io/specification/#securityRequirementObject) and `OpenApiSpex.SecurityRequirement` for more information. """ diff --git a/test/operation_dsl_test.exs b/test/controller_specs_test.exs similarity index 98% rename from test/operation_dsl_test.exs rename to test/controller_specs_test.exs index 277f1665..4eda830e 100644 --- a/test/operation_dsl_test.exs +++ b/test/controller_specs_test.exs @@ -1,4 +1,4 @@ -defmodule OpenApiSpex.OperationDslTest do +defmodule OpenApiSpex.ControllerSpecsTest do use ExUnit.Case, async: true import ExUnit.CaptureIO diff --git a/test/support/dsl_controller.ex b/test/support/dsl_controller.ex index 4c24400b..12ba859e 100644 --- a/test/support/dsl_controller.ex +++ b/test/support/dsl_controller.ex @@ -1,6 +1,6 @@ defmodule OpenApiSpexTest.DslController do use Phoenix.Controller - use OpenApiSpex.OperationDsl + use OpenApiSpex.ControllerSpecs defmodule UserParams do alias OpenApiSpex.Schema From 53f24917c9495c8e31cd1dbf25bbc7301bbb6011 Mon Sep 17 00:00:00 2001 From: Moxley Stratton Date: Sat, 5 Sep 2020 09:00:26 -0700 Subject: [PATCH 17/17] Documentation tweaks --- lib/open_api_spex/controller_specs.ex | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/open_api_spex/controller_specs.ex b/lib/open_api_spex/controller_specs.ex index d66015a4..36938afe 100644 --- a/lib/open_api_spex/controller_specs.ex +++ b/lib/open_api_spex/controller_specs.ex @@ -1,12 +1,13 @@ defmodule OpenApiSpex.ControllerSpecs do @moduledoc """ - Macros for defining operation specs and operation tags in a Phoenix controller. + Macros for defining operation specs, shared operation tags, and shared security specs + in a Phoenix controller. ## Example Here is an example Phoenix controller that uses the ControllerSpecs Operation specs: - defmodule MyAppWeb.DslController do + defmodule MyAppWeb.UserController do use Phoenix.Controller use OpenApiSpex.ControllerSpecs @@ -60,7 +61,7 @@ defmodule OpenApiSpex.ControllerSpecs do end @doc """ - Defines an Operation spec in a controller. + Defines an Operation spec for a controller action. ## Example