defmodule JungleSpec do @moduledoc """ A module providing simplified and less verbose way of definining OpenApiSpex types. JungleSpec provides two main entry points: `open_api_object` to define an object, and `open_api_type` to define a non-object type. Both generate an OpenApiSpec schema (`OpenApiSpex.Schema`) and an internal Elixir type. Only a single `open_api_object` or `open_api_type` clause is allowed per module, as the underlying Elixir type is defined as the `t()`-type within that module. The generated OpenApiSpex schema can be obtained by calling `schema/1` function on the module. The OpenAPI specification requires that each schemas has a unique name, called title. This is provided as the first argument for both `open_api_object` and `open_api_type` and must be a string. # Non-object Schemas `open_api_type` is used to define non-object schemas. That could be enumerable strings, regexp-limited strings, union types, and similar. Example: defmodule EnumExample do use JungleSpec open_api_type "NonObjectExample", :string, enum: ["ValueA", "ValueB"] end The above example will create the following OpenApiSpex schema struct: %OpenApiSpex.Schema%{ type: :string, enum: ["ValueA", "ValueB"], title: "NonObjectExample", nullable: false } Elixir-wise, the `t` type is defined as @type t() :: String.t() See `open_api_type/3` for more details in supported types and options. # Object Schemas `open_api_object/3` is used to specify object-type schemas. By default, object schemas are mapped to Elixir structs, but can be mapped to maps instead (see the "Elixir Struct or Map"-section for the details). `open_api_object` is used to set the title of the object-schema, and object-level options. The properties of the object are defined using the `property/3` macro. Here two parameters are required: The name and type of the property. Example: defmodule Person do use JungleSpec open_api_object "Person" do property :name, :string property :age, :integer end end This will generate the following OpenApiSpex Schema struct: %OpenApiSpex.Schema%{ type: :object, title: "Person", required: [:name, :age], nullable: false, properties: %{ name: %OpenApiSpex.Schema%{type: :string, nullable: false}, age: %OpenApiSpex.Schema%{type: :integer, nullable: false} }, "x-struct": Person } And the following Elixir type (struct): @type t() :: %Person{ age: integer(), name: String.t() } ## Required and Nullable Properties By default, all properties are "required". That means that the generated OpenApiSpec schema will have all properties in the list of `required` properties. It is possible to set `required: false` for a specific property. However, then a `default` value MUST be provided, or a compile-time error is emitted: defmodule Person do use JungleSpec open_api_object "Person" do property :name, :string property :age, :integer, required: false, default: 0 end end Please note, that even when `required: false` for a field, the Elixir struct still needs the value provided! It is also possible to set `required: false` on the entire object, and only setting `required: true` on some fields: defmodule Person do use JungleSpec open_api_object "Person", required: false do property :name, :string, required: true property :age, :integer, default: 0 end end A default value must still be provided for `:age` in order for this to compile. All properties have a default setting of `nullable: false`, i.e. they cannot be `nil`/`null`. By setting `nullable: true` on a property, `nil`/`null` becomes a valid value. This will also change the Elixir-struct, such that the property is no longer required (but will default to `nil` if not given) -- even when a default value other than `nil` is provided. ## Elixir Struct or Map Elixir structs have the property, that all declared fields are always present. If it is necessary to define an object, where not all properties are present all the time, we cannot map that into an Elixir struct. Instead we can map it to an Elixir map, by providing the `struct?: false` option to `open_api_object`: defmodule Person do use JungleSpec open_api_object "Person", struct?: false do property :name, :string property :age, :integer, required: false end end Note, that it is no longer necessary to provide a default value for `:age`, as the field is allowed not to be present. The Elixir type for this schema is: @type t() :: %{ age: integer(), name: String.t() } Two important things to note here: First, it is a map, an not a struct. Second, it wrongly, says that `:age` is required. The typespec should have had an `optional(age) => integer()`, but this has not yet been implemented. In practice, this is only an issue if the type is used, and Dialyzer catches the bug. The main important point of doing this, is that the generated OpenApiSpex schema struct instructs OpenApiSpex to generate a map and not a struct. Not optimal, but it works :-) ## Schema Extension (Inheritance) Sometimes, it can be helpful to create a schema that is an extension of another schema. The following schema extends the `Person` schema used as a previous example: defmodule Employee do use JungleSpec open_api_object "Employee", extends: Person do property :level, :string, enum: ["L1", "L2", "L3"] property :experience, [:number, :string] end end This will simply copy over all properties from `Person` and include them along with the the new ones provided. The OpenApiSpex schema structure would look like: %OpenApiSpex.Schema%{ type: :object, title: "Employee", required: [:name, :age, :level, :experience], nullable: false, properties: %{ name: %OpenApiSpex.Schema%{type: :string, nullable: false}, level: %OpenApiSpex.Schema%{ type: :string, enum: ["L1", "L2", "L3"], nullable: false }, experience: %OpenApiSpex.Schema%{ oneOf: [ %OpenApiSpex.Schema%{type: :number, nullable: false}, %OpenApiSpex.Schema%{type: :string, nullable: false} ] }, age: %OpenApiSpex.Schema%{type: :integer, nullable: false} }, "x-struct": Employee } While the Elixir type will be: @type t() :: %Employee{ age: integer(), experience: number() | String.t(), level: String.t(), name: String.t() } """ alias OpenApiSpex.Schema defmacro __using__(_) do quote do import JungleSpec, only: [ additional_properties: 2, open_api_object: 1, open_api_object: 2, open_api_object: 3, open_api_type: 3, property: 3 ] end end @doc """ Defines OpenApiSpex schema with `:object` type and appropriate typespec. It has one required argument: * `title` - a binary being a title of the schema It is followed by an optional keyword list of options and an optional `do ... end` block containing object's properties. Possible options * `:description` - a binary describing the whole object * `:example` - a map being an example of the object defined by the schema * `:extends` - a module of the schema that this schema extends. All properties of the extended schema will be added to the current schema, preserving the information if the properties are required. It does not propagate nullability of the extended schema. Ensure that `open_api_object/3` macro was used to define the extended module * `:inline` - a boolean value specifying the default `inline` option for all properties. By default it is `false` * `:nullable` - a boolean value specifying whether the object can be nullable. `false` by default * `:required` - a boolean value specifying the default `required` option for all properties. By default it is `true` * `:struct?` - a boolean value telling OpenApiSpex if it should create a struct out of the object. It will also add the struct to the typespec if set to `true`. By default it is `true` """ defmacro open_api_object(title, opts, do: block) do quote do Module.register_attribute(__MODULE__, :properties, accumulate: true) Module.register_attribute(__MODULE__, :additional_properties, accumulate: false) Module.register_attribute(__MODULE__, :references_to_modules, accumulate: true) import JungleSpec import JungleSpec.TypespecGenerator, only: [define_object_type: 4] require OpenApiSpex unquote(block) object_schema = prepare_object_schema(__MODULE__, unquote(title), @properties, @additional_properties, unquote(opts)) maybe_define_struct(object_schema, unquote(opts)) define_object_type(__MODULE__, object_schema, @references_to_modules, unquote(opts)) OpenApiSpex.schema(object_schema, struct?: false, derive?: false) end end defmacro open_api_object(title, do: block) do quote do open_api_object(unquote(title), [], do: unquote(block)) end end defmacro open_api_object(title, opts) do quote do open_api_object(unquote(title), unquote(opts), do: nil) end end defmacro open_api_object(title) do quote do open_api_object(unquote(title), [], do: nil) end end @doc """ Defines OpenApiSpex schema with type different from `:object` and appropriate typespec. It has two required arguments: * `title` - a binary being a title of the schema * `type` - an atom proving the type of the schema. It is followed by an optional keyword list of options. Allowed types: * `:integer` - maps to the type of the same name. * `:number` - either integer or float. Maps to the type of the same name. * `:string` - a binary. Maps to the type of the same name. * `:boolean` - maps to the type of the same name. * `{:array, type}` - a list of elements having provided type. `type` have to be one of the allowed types. * `{:map, type}` - a nested object with additional properties having provided `type`, which have to be one of the allowed types. * `[type_1, type_2, ...]` - a union of the provided types. The types have to be allowed. * `module` - a module name that has it's own schema. Supported options are: * `:default` - default value for the property. It has to match type of the property. * `:description` - a binary describing the property. * `:enum` - a list of possible values which have to be binaries. Only valid for `:string` type. * `:example` - an example of the property. It has to match its type. * `:format` - a binary describing property's format. * `:inline` - a boolean value that is used if the property's type is another module. Then, if `inline: true`, the module's schema is just inlined. Otherwise, only the reference is used. By default, the value is propagated from the object. * `:nullable` - a boolean value specifying if the property can be `nil`. `false` by default. * `:pattern` - a regular expression describing possible format of the property. Only valid for `:string` type. """ defmacro open_api_type(title, type, opts \\ []) do quote do Module.register_attribute(__MODULE__, :references_to_modules, accumulate: true) import JungleSpec import JungleSpec.TypespecGenerator, only: [define_type: 2] require OpenApiSpex type_schema = prepare_type_schema(__MODULE__, unquote(title), unquote(type), unquote(opts)) define_type(type_schema, @references_to_modules) OpenApiSpex.schema(type_schema, struct?: false) end end @doc """ Defines one property for the open_api_object. It has two required arguments: * `name` - an atom being a name of the property * `type` - an atom or a tuple proving the type of the schema Allowed types: * `:integer` - maps to the type of the same name * `:number` - either integer or float. Maps to the type of the same name * `:string` - a binary. Maps to the type of the same name * `:boolean` - maps to the type of the same name * `{:array, type}` - a list of elements having provided type. `type` have to be one of the allowed types * `{:map, type}` - a nested object with additional properties having provided `type`, which have to be one of the allowed types * `[type_1, type_2, ...]` - a union of the provided types. The types have to be allowed * `module` - a module name that has it's own schema Property also has an optional keyword list with the following possible options: * `:default` - default value for the property. It has to match type of the property * `:description` - a binary describing the property * `:enum` - a list of possible values which have to be binaries. Also, property has to have `:string` type * `:example` - an example of the property. It has to match its type * `:format` - a binary describing property's format * `:inline` - a boolean value that is used if the property's type is another module. Then, if `inline: true`, the module's schema is just inlined. Otherwise, only the reference is used. By default, the value is propagated from the object * `:nullable` - a boolean value specifying if the property can be `nil`. `false` by default * `:pattern` - a regular expression describing possible format of the property * `:required` - a boolean value specifying if the property should be added to the list of object's required properties. By default, it is propagated from the object """ defmacro property(name, type, opts \\ []) do quote do JungleSpec.define_property(__MODULE__, unquote(name), unquote(type), unquote(opts)) end end @doc """ Macro for defining object's `additionalProperties`. It has one required argument: * `type` - type have to be one of the allowed types defined in the `property` macro It has also one option: * `:nullable` - a boolean value specifying if the additional properties can be nullable. `false` by default """ defmacro additional_properties(type, opts \\ []) do quote do Module.put_attribute(__MODULE__, :additional_properties, {unquote(type), unquote(opts)}) end end def prepare_object_schema(module, title, properties, additional_properties, opts) do properties = propagate_general_opts(properties, opts) validate_object_opts!(properties, additional_properties, opts) nullable = Keyword.get(opts, :nullable, false) properties_schemas = Map.new(properties, fn {name, type, property_opts} -> {name, prepare_property_schema(module, title, name, type, property_opts)} end) required = required_properties_names(properties) schema_map = %{ nullable: nullable, properties: properties_schemas, required: required, title: title, type: :object } |> maybe_add_additional_properties(module, title, additional_properties) |> maybe_add_opts([:description, :example], opts) |> maybe_add_xstruct(module, opts) |> maybe_extend_object(opts) struct(Schema, schema_map) end def prepare_type_schema(module, title, type, opts) do %Schema{prepare_property_schema(module, title, title, type, opts) | title: title} end defp propagate_general_opts(properties, opts) do general_opts = [ inline: Keyword.get(opts, :inline, false), required: Keyword.get(opts, :required, true) ] Enum.map(properties, fn {name, type, property_opts} -> {name, type, Keyword.merge(general_opts, property_opts)} end) end defp validate_object_opts!(properties, additional_properties, object_opts) do struct? = Keyword.get(object_opts, :struct?, true) if struct? and not is_nil(additional_properties) do raise ArgumentError, "Struct cannot be defined with additional_properties" end if struct? and Enum.any?(properties, fn {_name, _type, property_opts} -> required = Keyword.get(property_opts, :required) nullable = Keyword.get(property_opts, :nullable, false) has_default = not (property_opts |> Keyword.get(:default) |> is_nil()) not required and not nullable and not has_default end) do raise ArgumentError, "Struct cannot have not required properties which are not nullable and do not have default values" end end defp prepare_property_schema(module, title, name, {:array, type}, opts) do validate_opts!(name, {:array, type}, opts) items_schema = prepare_property_schema(module, title, name, type, clear_opts_for_nested_types(opts)) nullable = Keyword.get(opts, :nullable, false) schema_map = maybe_add_opts( %{type: :array, items: items_schema, nullable: nullable}, [:description, :default], opts ) struct(Schema, schema_map) end defp prepare_property_schema(module, title, name, {:map, type}, opts) do validate_opts!(name, {:map, type}, opts) nested_properties_schema = prepare_property_schema(module, title, name, type, clear_opts_for_nested_types(opts)) nullable = Keyword.get(opts, :nullable, false) schema_map = maybe_add_opts( %{type: :object, properties: %{}, additionalProperties: nested_properties_schema, nullable: nullable}, [:description, :default], opts ) struct(Schema, schema_map) end defp prepare_property_schema(module, title, name, union_types, opts) when is_list(union_types) do validate_opts!(name, union_types, opts) items_schema = union_types |> Enum.map(&prepare_property_schema(module, title, name, &1, clear_opts_for_nested_types(opts))) |> Enum.uniq() schema_map = maybe_add_opts( %{oneOf: items_schema}, [:description, :default], opts ) struct(Schema, schema_map) end @primitive_types [:integer, :number, :string, :boolean] defp prepare_property_schema(_module, _title, name, type, opts) when type in @primitive_types do validate_opts!(name, type, opts) nullable = Keyword.get(opts, :nullable, false) schema_map = maybe_add_opts( %{type: type, nullable: nullable}, [:description, :default, :format, :pattern, :example, :enum], opts ) struct(Schema, schema_map) end defp prepare_property_schema(module, title, name, property_module, opts) do validate_opts!(name, property_module, opts) Code.ensure_compiled!(property_module) module_schema = if Keyword.get(opts, :inline, false) do property_module else reference = if module == property_module do "#/components/schemas/" <> title else "#/components/schemas/" <> property_module.schema().title end Module.put_attribute(module, :references_to_modules, {reference, property_module}) %OpenApiSpex.Reference{"$ref": reference} end nullable = Keyword.get(opts, :nullable, false) cond do Keyword.has_key?(opts, :default) -> default = Keyword.get(opts, :default) %Schema{oneOf: [module_schema], default: default, nullable: nullable} nullable -> %Schema{oneOf: [module_schema], nullable: nullable} true -> module_schema end end defp validate_opts!(name, type, opts) do if Keyword.has_key?(opts, :enum) do if type != :string do raise ArgumentError, "#{name} has an enum option, but it can be provided only for string type" end if opts |> Keyword.get(:enum) |> Enum.any?(fn item -> not is_binary(item) end) do raise ArgumentError, "#{name} has values of invalid types in the enum option. They should be binaries" end end if Keyword.has_key?(opts, :default) do if not (opts |> Keyword.get(:default) |> has_valid_type?(type)) do raise ArgumentError, "default value of #{name} does not match its type" end end end defp has_valid_type?(default, types_union) when is_list(types_union) do Enum.any?(types_union, fn expected_type -> has_valid_type?(default, expected_type) end) end defp has_valid_type?(default, expected_type) when is_integer(default) do expected_type in [:integer, :number] end defp has_valid_type?(default, expected_type) when is_number(default) do expected_type == :number end defp has_valid_type?(default, expected_type) when is_binary(default) do expected_type == :string end defp has_valid_type?(default, expected_type) when is_boolean(default) do expected_type == :boolean end defp has_valid_type?(default, {:array, expected_type}) when is_list(default) do Enum.all?(default, fn default_item -> has_valid_type?(default_item, expected_type) end) end defp has_valid_type?(default, {:map, expected_type}) when is_map(default) do Enum.all?(default, fn {key, value} -> is_binary(key) and has_valid_type?(value, expected_type) end) end defp has_valid_type?(_default, _expected_type), do: false defp clear_opts_for_nested_types(opts) do Enum.reduce([:nullable, :default, :description, :enum], opts, fn key, opts -> Keyword.delete(opts, key) end) end defp required_properties_names(properties) do properties |> Enum.filter(fn {_name, _type, opts} -> Keyword.get(opts, :required, true) end) |> Enum.map(fn {name, _type, _opts} -> name end) |> Enum.reverse() end defp maybe_add_additional_properties(schema_map, module, title, {type, opts}) do additional_properties_schema = prepare_property_schema(module, title, :additional_properties, type, opts) Map.put(schema_map, :additionalProperties, additional_properties_schema) end defp maybe_add_additional_properties(schema_map, _module, _title, nil) do schema_map end defp maybe_add_opts(schema_map, keys, opts) do Enum.reduce(keys, schema_map, fn key, map -> if Keyword.has_key?(opts, key) do Map.put(map, key, Keyword.get(opts, key)) else map end end) end defp maybe_add_xstruct(schema, module, opts) do if Keyword.get(opts, :struct?, true) do Map.put(schema, :"x-struct", module) else schema end end defp maybe_extend_object(schema_map, opts) do if Keyword.has_key?(opts, :extends) do module = Keyword.get(opts, :extends) Code.ensure_compiled!(module) properties_to_add = module.schema().properties required_to_add = module.schema().required schema_map |> Map.update(:properties, properties_to_add, fn properties -> Map.merge(properties_to_add, properties) end) |> Map.update(:required, required_to_add, fn required -> required_to_add ++ required end) else schema_map end end defmacro maybe_define_struct(schema, opts) do quote do struct? = Keyword.get(unquote(opts), :struct?, true) if struct? do @derive Enum.filter([Poison.Encoder, Jason.Encoder], &Code.ensure_loaded?/1) @enforce_keys enforced_properties(unquote(schema)) defstruct struct_definition(unquote(schema)) end unquote(schema) end end def enforced_properties(schema) do schema.properties |> Enum.filter(fn {_name, %Schema{nullable: nil}} -> false {_name, %Schema{nullable: nullable}} -> not nullable {_name, %Schema{default: _default}} -> false {_name, _module} -> true end) |> Enum.map(fn {name, _property_schema} -> name end) end def struct_definition(schema) do Enum.map(schema.properties, fn {name, property_schema} -> {name, default_value(property_schema)} end) end defp default_value(%Schema{default: default}), do: default defp default_value(_), do: nil def define_property(module, name, type, opts) do if module |> Module.get_attribute(:properties) |> Keyword.has_key?(name) do raise ArgumentError, "the property #{inspect(name)} is already set" end Module.put_attribute(module, :properties, {name, type, opts}) end end