diff --git a/lib/open_api_spex/json_api_helpers.ex b/lib/open_api_spex/json_api_helpers.ex new file mode 100644 index 00000000..3ea34aab --- /dev/null +++ b/lib/open_api_spex/json_api_helpers.ex @@ -0,0 +1,39 @@ +defmodule OpenApiSpex.JsonApiHelpers do + alias OpenApiSpex.JsonApiHelpers.{JsonApiDocument, JsonApiResource} + + def document_schema(document) do + JsonApiDocument.schema(document) + end + + def resource_schema(resource) do + JsonApiResource.schema(resource) + end + + defmacro generate_document_schema(attrs) do + quote do + require OpenApiSpex + + @document struct!(JsonApiDocument, unquote(attrs)) + def document, do: @document + + @document_schema JsonApiDocument.schema(@document) + def document_schema, do: @document_schema + + OpenApiSpex.schema(@document_schema) + end + end + + defmacro generate_resource_schema(attrs) do + quote do + require OpenApiSpex + + @resource struct!(JsonApiResource, unquote(attrs)) + def resource, do: @resource + + @resource_schema JsonApiResource.schema(@resource) + def resource_schema, do: @resource_schema + + OpenApiSpex.schema(@resource_schema) + end + end +end diff --git a/lib/open_api_spex/json_api_helpers/json_api_document.ex b/lib/open_api_spex/json_api_helpers/json_api_document.ex new file mode 100644 index 00000000..736b1522 --- /dev/null +++ b/lib/open_api_spex/json_api_helpers/json_api_document.ex @@ -0,0 +1,95 @@ +defmodule OpenApiSpex.JsonApiHelpers.JsonApiDocument do + alias OpenApiSpex.Schema + alias OpenApiSpex.JsonApiHelpers.JsonApiResource + + defstruct resource: nil, + multiple: false, + paginated: false, + title: nil, + "x-struct": nil + + def schema(%__MODULE__{} = document) do + if not is_binary(document.title) do + raise "%JsonApiDocument{} :title is required and must be a string" + end + + resource = document.resource + resource_item_schema = JsonApiResource.schema(resource) + + resource_title = + case resource_item_schema do + %Schema{} = schema -> schema.title + module when is_atom(module) and not is_nil(module) -> module.schema().title + end + + resource_schema = + if document.multiple || document.paginated do + %Schema{ + type: :array, + items: resource_item_schema, + title: resource_title <> "List" + } + else + resource_item_schema + end + + properties = %{ + data: resource_schema + } + + properties = + if document.paginated do + Map.put(properties, :links, pagination_spec()) + else + properties + end + + %Schema{ + type: :object, + properties: properties, + required: [:data], + title: document.title, + "x-struct": document."x-struct" + } + end + + def schema(document_attrs) when is_list(document_attrs) or is_map(document_attrs) do + __MODULE__ + |> struct!(document_attrs) + |> schema() + end + + def pagination_spec() do + # https://jsonapi.org/format/#fetching-pagination + # TODO: Links can be omitted or nullable, nullable should be delcared! + %Schema{ + type: :object, + properties: %{ + prev: %Schema{ + type: :string, + description: "Link to the previous page of results", + nullable: true, + readOnly: true + }, + next: %Schema{ + type: :string, + description: "Link to the next page of results", + nullable: true, + readOnly: true + }, + last: %Schema{ + type: :string, + description: "Link to the last page of results", + nullable: true, + readOnly: true + }, + first: %Schema{ + type: :string, + description: "Link to the first page of results", + nullable: true, + readOnly: true + } + } + } + end +end diff --git a/lib/open_api_spex/json_api_helpers/json_api_error.ex b/lib/open_api_spex/json_api_helpers/json_api_error.ex new file mode 100644 index 00000000..d17ca9a7 --- /dev/null +++ b/lib/open_api_spex/json_api_helpers/json_api_error.ex @@ -0,0 +1,46 @@ +defmodule OpenApiSpex.JsonApiHelpers.JsonApiError do + alias OpenApiSpex.Schema + + @moduledoc """ + @see https://jsonapi.org/format/#errors + """ + + @behaviour OpenApiSpex.Schema + def schema do + %OpenApiSpex.Schema{ + title: "JsonApiError", + type: :object, + properties: %{ + id: %Schema{ + type: :string, + description: "A unique identifier for this particular occurrence of the problem." + }, + status: %Schema{ + type: :string, + description: + "The HTTP status code applicable to this problem, expressed as a string value." + }, + code: %Schema{ + type: :string, + description: "An application-specific error code, expressed as a string value." + }, + title: %Schema{ + type: :string, + description: + "A short, human-readable summary of the problem that *SHOULD NOT* change from occurrence to occurrence of the problem, except for purposes of localization." + }, + detail: %Schema{ + type: :string, + description: "A human-readable explanation specific to this occurrence of the problem." + } + # TODO: Props: + # links: %Schema{type: JsonApiLinks, description: "a links object containing the following members"}, + # source: %Schema{type: NEED_PROPPER_DEFINITION, description: "An object containing references to the source of the error."}, + # meta: %Schema{ + # type: NEED_PROPPER_DEFINITION, + # description: "A meta object containing non-standard meta-information about the error." + # } + } + } + end +end diff --git a/lib/open_api_spex/json_api_helpers/json_api_resource.ex b/lib/open_api_spex/json_api_helpers/json_api_resource.ex new file mode 100644 index 00000000..35db7406 --- /dev/null +++ b/lib/open_api_spex/json_api_helpers/json_api_resource.ex @@ -0,0 +1,42 @@ +defmodule OpenApiSpex.JsonApiHelpers.JsonApiResource do + alias OpenApiSpex.Schema + + defstruct additionalProperties: nil, + properties: %{}, + required: [], + title: nil + + def schema(resource) when is_atom(resource) and not is_nil(resource) do + resource.schema()."x-struct" + end + + def schema(%__MODULE__{} = resource) do + if not is_binary(resource.title) do + raise "%JsonApiResource{} :title is required and must be a string" + end + + %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string}, + type: %Schema{type: :string}, + attributes: attributes_schema(resource) + }, + # For responses, :id must be required too + required: [:type], + title: resource.title <> "Resource" + } + end + + def attributes_schema(%__MODULE__{} = resource) do + if not is_binary(resource.title) do + raise "%JsonApiResource{} :title is required and must be a string" + end + + %Schema{ + type: :object, + properties: resource.properties, + title: resource.title <> "Attributes" + } + end +end diff --git a/lib/open_api_spex/open_api/schema_extension.ex b/lib/open_api_spex/open_api/schema_extension.ex new file mode 100644 index 00000000..0ec87c20 --- /dev/null +++ b/lib/open_api_spex/open_api/schema_extension.ex @@ -0,0 +1,3 @@ +defmodule OpenApiSpex.OpenApi.SchemaExtension do + defstruct [:struct, :validate, registered: false] +end diff --git a/lib/open_api_spex/schema.ex b/lib/open_api_spex/schema.ex index 4395b44f..a4a5812c 100644 --- a/lib/open_api_spex/schema.ex +++ b/lib/open_api_spex/schema.ex @@ -183,7 +183,8 @@ defmodule OpenApiSpex.Schema do :example, :deprecated, :"x-struct", - :"x-validate" + :"x-validate", + :"x-register" ] @typedoc """ diff --git a/lib/open_api_spex/schema_resolver.ex b/lib/open_api_spex/schema_resolver.ex index 2f0876d9..16890ad2 100644 --- a/lib/open_api_spex/schema_resolver.ex +++ b/lib/open_api_spex/schema_resolver.ex @@ -217,6 +217,13 @@ defmodule OpenApiSpex.SchemaResolver do properties: properties } + schemas = + if schema."x-struct" do + Map.put(schemas, schema.title, schema) + else + schemas + end + {schema, schemas} end diff --git a/test/json_api_helpers_test.exs b/test/json_api_helpers_test.exs new file mode 100644 index 00000000..6c8d4800 --- /dev/null +++ b/test/json_api_helpers_test.exs @@ -0,0 +1,103 @@ +defmodule OpenApiSpex.JsonApiHelpersTest do + use ExUnit.Case, async: true + + alias OpenApiSpexTest.JsonApiSchemas.{ + CartResource, + CartDocument, + CartPaginatedDocument, + CartListDocument + } + + alias OpenApiSpex.{JsonApiHelpers, Schema} + alias OpenApiSpex.JsonApiHelpers.JsonApiResource + + # describe "from operation specs" do + # test "index action" do + # spec = OpenApiSpexTest.ApiSpec.spec() + # assert %Schema{} = _schema = spec.components.schemas["CartAnnotatedDocument"] + # end + # end + + describe "generate_resource_document/1" do + test "generate schema/0" do + assert %Schema{} = schema = CartDocument.schema() + assert schema.title == "CartDocument" + assert %{data: _} = schema.properties + assert schema.properties.data.schema().title == CartResource.schema().title + end + + test "generate schema for index document" do + assert %Schema{} = schema = CartListDocument.schema() + assert schema.title == "CartListDocument" + assert %{data: _} = schema.properties + assert schema.properties.data.type == :array + assert schema.properties.data.items == CartResource + end + + test "generate schema for paginated document" do + assert %Schema{} = schema = CartPaginatedDocument.schema() + assert schema.title == "CartPaginatedDocument" + assert %{data: _} = schema.properties + assert schema.properties.data.type == :array + assert schema.properties.data.items == CartResource + + # Check for meta fields + assert %{links: links} = schema.properties + assert links.properties.first.type == :string + assert links.properties.last.type == :string + assert links.properties.prev.type == :string + assert links.properties.next.type == :string + end + end + + describe "generate_resource_schema/1" do + test "generate resource/0 and resource_schema/0" do + assert %JsonApiResource{} = CartResource.resource() + assert %Schema{} = schema = CartResource.schema() + assert schema.title == "CartResource" + end + end + + describe "resource_schema/1" do + test "attributes" do + resource = CartResource.resource() + schema = JsonApiHelpers.resource_schema(resource) + assert schema.properties.attributes == JsonApiResource.attributes_schema(resource) + assert %Schema{} = schema.properties.id + assert %Schema{} = schema.properties.type + end + + test "title" do + resource = CartResource.resource() + schema = JsonApiHelpers.resource_schema(resource) + assert schema.title == "CartResource" + end + end + + describe "attributes_schema/1" do + test "generates schema with same properties" do + resource = CartResource.resource() + schema = JsonApiResource.attributes_schema(resource) + assert schema.properties == resource.properties + end + + test "generates title" do + resource = CartResource.resource() + schema = JsonApiResource.attributes_schema(resource) + assert schema.title == "CartAttributes" + end + + test ":title must be a string" do + resource = CartResource.resource() + resource = %{resource | title: nil} + + assert_raise( + RuntimeError, + "%JsonApiResource{} :title is required and must be a string", + fn -> + JsonApiResource.attributes_schema(resource) + end + ) + end + end +end diff --git a/test/plug/cast_test.exs b/test/plug/cast_test.exs index 8e38c5e7..d730f28c 100644 --- a/test/plug/cast_test.exs +++ b/test/plug/cast_test.exs @@ -318,4 +318,54 @@ defmodule OpenApiSpex.Plug.CastTest do assert Jason.decode!(conn.resp_body) == %{"_json" => [%{"one" => "this"}]} end end + + describe "json-api schemas" do + test "create cart" do + request_body = %{ + "data" => %{ + "attributes" => %{"total" => 200}, + "type" => "cart", + "id" => "492e7435-cd23-4ce0-a189-edf7fdc4b490" + } + } + + conn = + :post + |> Plug.Test.conn("api/jsonapi/carts", Jason.encode!(request_body)) + |> Plug.Conn.put_req_header("content-type", "application/json") + |> OpenApiSpexTest.Router.call([]) + + assert Jason.decode!(conn.resp_body) == request_body + end + + test "list carts" do + conn = + :get + |> Plug.Test.conn("api/jsonapi/carts") + |> OpenApiSpexTest.Router.call([]) + + assert Jason.decode!(conn.resp_body) == %{"data" => []} + end + + test "paginated carts" do + conn = + :get + |> Plug.Test.conn("api/jsonapi/carts-paginated?page[number]=1&page[size]=10") + |> OpenApiSpexTest.Router.call([]) + + assert Jason.decode!(conn.resp_body) == %{ + "data" => [], + "links" => %{ + "first" => + "https://example.com/api/jsonapi/carts-paginated?page[number]=1&page[size]=10", + "last" => + "https://example.com/api/jsonapi/carts-paginated?page[number]=10&page[size]=10", + "next" => + "https://example.com/api/jsonapi/carts-paginated?page[number]=2&page[size]=10", + "prev" => + "https://example.com/api/jsonapi/carts-paginated?page[number]=0&page[size]=10" + } + } + end + end end diff --git a/test/support/api_spec.ex b/test/support/api_spec.ex index 1a7c1dd8..9210d376 100644 --- a/test/support/api_spec.ex +++ b/test/support/api_spec.ex @@ -35,7 +35,7 @@ defmodule OpenApiSpexTest.ApiSpec do }, components: %Components{ schemas: - for schemaMod <- [ + for schema_mod <- [ Schemas.Pet, Schemas.PetType, Schemas.Cat, @@ -46,7 +46,7 @@ defmodule OpenApiSpexTest.ApiSpec do Schemas.Primitive ], into: %{} do - schema = schemaMod.schema() + schema = schema_mod.schema() {schema.title, schema} end, parameters: %{ diff --git a/test/support/controllers/json_api_controller.ex b/test/support/controllers/json_api_controller.ex new file mode 100644 index 00000000..ff18add4 --- /dev/null +++ b/test/support/controllers/json_api_controller.ex @@ -0,0 +1,140 @@ +defmodule OpenApiSpexTest.JsonApiController do + use Phoenix.Controller + use OpenApiSpex.Controller + + alias OpenApiSpex.JsonApiHelpers + + alias OpenApiSpexTest.JsonApiSchemas + + require OpenApiSpex.JsonApiHelpers + + plug OpenApiSpex.Plug.CastAndValidate, json_render_error_v2: true + + @resource JsonApiSchemas.CartResource + @resource_document JsonApiSchemas.CartDocument + + @doc """ + Get a list of carts. + """ + @doc responses: [ + ok: { + "Carts", + "application/json", + JsonApiSchemas.CartListDocument + } + ] + def index(conn, _params) do + json(conn, %{data: []}) + end + + @doc """ + Get a list of carts, but paginated. + """ + @doc parameters: [ + page: [in: :query, type: JsonApiSchemas.PageBasedPaginationParameter, required: true] + ], + responses: [ + ok: { + "Carts", + "application/json", + JsonApiSchemas.CartPaginatedDocument + } + ] + def paginated_index(conn, %{page: page}) do + page_params = Map.from_struct(page) + uri = %URI{scheme: "https", host: "example.com"} + + paged_url_fn = + &OpenApiSpexTest.Router.Helpers.json_api_url(uri, :paginated_index, + page: %{page_params | number: &1} + ) + + response = %{ + data: [], + links: %{ + first: paged_url_fn.(1), + last: paged_url_fn.(10), + prev: paged_url_fn.(page.number - 1), + next: paged_url_fn.(page.number + 1) + } + } + + json(conn, response) + end + + @spec annotated_index(Plug.Conn.t(), any) :: Plug.Conn.t() + @doc """ + Get a list of carts, but defined in annotation. + """ + @doc responses: [ + ok: { + "Carts", + "application/json", + JsonApiHelpers.document_schema( + title: "CartAnnotatedDocument", + resource: @resource, + multiple: true + ) + } + ] + def annotated_index(conn, _params) do + json(conn, %{data: []}) + end + + @doc """ + Create a Cart. + """ + @doc request_body: {"CartDocument", "application/json", @resource_document}, + responses: [ + created: {"CartDocument", "application/json", @resource_document} + ] + def create(%Plug.Conn{body_params: payload} = conn, _params) do + response = %{ + data: %JsonApiSchemas.CartResource{ + id: "492e7435-cd23-4ce0-a189-edf7fdc4b490", + type: "cart", + attributes: payload.data.attributes + } + } + + conn + |> put_status(:created) + |> json(response) + end + + @doc """ + Get a cart by ID. + """ + @doc responses: [ + ok: { + "Cart", + "application/json", + @resource_document + } + ] + def show(conn, _params) do + json(conn, %{data: %{}}) + end + + @doc """ + Updating a Cart's Attributes + + This action is available as a PUT or a PATCH. + """ + @doc request_body: + {"The cart attributes", "application/json", @resource_document, required: true}, + responses: [ + created: {"Car", "application/json", @resource_document} + ] + def update(conn, _) do + json(conn, %@resource_document{ + data: %@resource{ + id: "123", + type: "cart", + attributes: %{ + total: "4000" + } + } + }) + end +end diff --git a/test/support/json_api_schemas.ex b/test/support/json_api_schemas.ex new file mode 100644 index 00000000..4d7315a2 --- /dev/null +++ b/test/support/json_api_schemas.ex @@ -0,0 +1,54 @@ +defmodule OpenApiSpexTest.JsonApiSchemas do + alias OpenApiSpex.JsonApiHelpers + alias OpenApiSpex.Schema + alias OpenApiSpex.JsonApiHelpers.JsonApiDocument + alias OpenApiSpex.JsonApiHelpers.JsonApiResource + + require OpenApiSpex.JsonApiHelpers + + defmodule CartResource do + JsonApiHelpers.generate_resource_schema( + title: "Cart", + properties: %{ + total: %Schema{type: :integer} + }, + additionalProperties: false + ) + end + + defmodule PageBasedPaginationParameter do + require OpenApiSpex + + OpenApiSpex.schema(%{ + title: "PageBasedPaginationParameter", + type: :object, + properties: %{ + number: %Schema{type: :integer}, + size: %Schema{type: :integer} + } + }) + end + + defmodule CartDocument do + JsonApiHelpers.generate_document_schema( + title: "CartDocument", + resource: CartResource + ) + end + + defmodule CartListDocument do + JsonApiHelpers.generate_document_schema( + title: "CartListDocument", + paginated: true, + resource: CartResource + ) + end + + defmodule CartPaginatedDocument do + JsonApiHelpers.generate_document_schema( + title: "CartPaginatedDocument", + paginated: true, + resource: CartResource + ) + end +end diff --git a/test/support/router.ex b/test/support/router.ex index 33cdf712..0e064ee9 100644 --- a/test/support/router.ex +++ b/test/support/router.ex @@ -29,5 +29,9 @@ defmodule OpenApiSpexTest.Router do post "/utility/echo/body_params", UtilityController, :echo_body_params get "/json_render_error", JsonRenderErrorController, :index + + resources "/jsonapi/carts", JsonApiController, only: [:create, :index, :show] + get "/jsonapi/carts-paginated", JsonApiController, :paginated_index + get "/jsonapi/carts-annotated", JsonApiController, :annotated_index end end diff --git a/test/support/router_2.ex b/test/support/router_2.ex new file mode 100644 index 00000000..35efbbce --- /dev/null +++ b/test/support/router_2.ex @@ -0,0 +1,17 @@ +defmodule OpenApiSpexTest.Router2 do + use Phoenix.Router + alias Plug.Parsers + alias OpenApiSpex.Plug.PutApiSpec + + pipeline :api do + plug :accepts, ["json"] + plug PutApiSpec, module: OpenApiSpexTest.ApiSpec + plug Parsers, parsers: [:json], pass: ["text/*"], json_decoder: Jason + end + + scope "/api", OpenApiSpexTest do + pipe_through :api + get "/jsonapi/carts", JsonApiController, :index + # get "/jsonapi/carts/:id", JsonApiController, :show + end +end