defmodule OpenAPI.Processor do @moduledoc """ Phase two of code generation The **process** phase begins with a decoded API description from the **read** phase. It may represent the contents of one or more files, including one or more root descriptions if supplemental files were used. This library takes an "operation first" mindset when processing the description. Schemas are ignored until they are referenced by an operation (ex. as a response body). It is the job of this phase to observe all of the operations and their referenced schemas, process them into data structures more relevant to code generation, and prepare the data for rendering. ## Customization At several points during code generation, it may be useful to customize the behaviour of the processor. For this purpose, this module is a Behaviour with most of its critical logic implemented as optional callbacks. """ alias OpenAPI.Processor.Format alias OpenAPI.Processor.Operation alias OpenAPI.Processor.Operation.Param alias OpenAPI.Processor.Schema alias OpenAPI.Processor.Schema.Field alias OpenAPI.Processor.State alias OpenAPI.Processor.Type alias OpenAPI.Spec alias OpenAPI.Spec.Path.Operation, as: OperationSpec alias OpenAPI.Spec.Schema, as: SchemaSpec @doc """ Run the processing phase of the code generator This functions is used by the main `OpenAPI` module. It is unlikely that you will call this function directly, unless you are reimplementing one of the core functions. If this happens, please share your use-case with the maintainers; a plugin might be warranted. """ @spec run(OpenAPI.State.t()) :: OpenAPI.State.t() def run(state) do %State{operations: operations, schemas_by_ref: schemas_by_ref} = state |> State.new() |> collect_operations_and_schemas() |> record_schema_output_format() |> record_schema_module_and_type() %OpenAPI.State{state | operations: operations, schemas: schemas_by_ref} end # # Integration # defmacro __using__(_opts) do quote do @behaviour OpenAPI.Processor defdelegate ignore_operation?(state, operation), to: OpenAPI.Processor defdelegate ignore_schema?(state, schema), to: OpenAPI.Processor defdelegate operation_docstring(state, operation_spec, params), to: OpenAPI.Processor defdelegate operation_function_name(state, operation_spec), to: OpenAPI.Processor defdelegate operation_module_names(state, operation_spec), to: OpenAPI.Processor defdelegate operation_request_body(state, operation_spec), to: OpenAPI.Processor defdelegate operation_request_method(state, operation_spec), to: OpenAPI.Processor defdelegate operation_response_body(state, operation_spec), to: OpenAPI.Processor defdelegate schema_format(state, schema), to: OpenAPI.Processor defdelegate schema_module_and_type(state, schema), to: OpenAPI.Processor defoverridable ignore_operation?: 2, ignore_schema?: 2, operation_docstring: 3, operation_function_name: 2, operation_module_names: 2, operation_request_body: 2, operation_request_method: 2, operation_response_body: 2, schema_format: 2, schema_module_and_type: 2 end end # # Callbacks # @optional_callbacks ignore_operation?: 2, ignore_schema?: 2, operation_docstring: 3, operation_function_name: 2, operation_module_names: 2, operation_request_body: 2, operation_request_method: 2, operation_response_body: 2, schema_format: 2, schema_module_and_type: 2 @doc """ Whether to render the given operation in the generated code If this function returns `true`, the operation will not appear in the generated code. See `OpenAPI.Processor.Ignore.ignore_operation?/2` for the default implementation. """ @callback ignore_operation?(state :: State.t(), operation_spec :: OperationSpec.t()) :: boolean @doc """ Whether to render the given schema in the generated code If this function returns `true`, the schema will not appear in the generated code (unless it returns `false` when presented in another context) and a plain `map` will be used as its type. See `OpenAPI.Processor.Ignore.ignore_schema?/2` for the default implementation. """ @callback ignore_schema?(state :: State.t(), schema_spec :: SchemaSpec.t()) :: boolean @doc """ Construct a docstring for the given operation This function accepts the operation spec as well as a list of the **processed** query params associated with the operation. See `OpenAPI.Processor.Operation.docstring/3` for the default implementation. """ @callback operation_docstring( state :: State.t(), operation_spec :: OperationSpec.t(), query_params :: [Param.t()] ) :: String.t() @doc """ Choose the name of the client function for the given operation This function accepts the operation spec and chooses a name for the client function that will be generated. The name must be unique within its module (see `c:operation_module_names/2`). See `OpenAPI.Processor.Naming.operation_function/2` for the default implementation. """ @callback operation_function_name(state :: State.t(), operation_spec :: OperationSpec.t()) :: atom @doc """ Choose the names of the client function modules for the given operation Each operation may have multiple client functions in different modules. This function accepts the operation spec and returns a list of all of its operation modules. See `OpenAPI.Processor.Naming.operation_modules/2` for the default implementation. """ @callback operation_module_names(state :: State.t(), operation_spec :: OperationSpec.t()) :: [ module ] @doc """ Collect a list of request content types and their associated schemas This function accepts the operation spec and returns a list of tuples containing the content type (ex. "application/json") and the schema associated with that type. See `OpenAPI.Processor.Operation.request_body/2` for the default implementation. """ @callback operation_request_body(state :: State.t(), operation_spec :: OperationSpec.t()) :: Operation.request_body_unprocessed() @doc """ Choose and cast the request method for the given operation This function accepts the operation spec and must return the (lowercase) atom representing the HTTP method. See `OpenAPI.Processor.Operation.request_method/2` for the default implementation. """ @callback operation_request_method(state :: State.t(), operation_spec :: OperationSpec.t()) :: Operation.method() @doc """ Collect a list of response status codes and their associated schemas This function accepts the operation spec and returns a list of tuples containing the status codes (ex. `200` or `:default`) and a list of tuples with the possible content types and the schema associated with that code and type. See `OpenAPI.Processor.Operation.response_body/2` for the default implementation. """ @callback operation_response_body(state :: State.t(), operation_spec :: OperationSpec.t()) :: Operation.response_body_unprocessed() @doc """ Choose the output format for the given schema Schemas can be output in a number of different ways, the most common of which are structs and typed maps. This callback chooses which output format to use for a processed schema. This decision may influence the naming (module and type) given to the schema. The special value `:unknown` may be returned from this callback to delay / re-enqueue the schema for processing. This may appropriate, for example, if the schema's parent has not yet been processed. Note that continually returning `:unknown` can cause an infinite loop. See `OpenAPI.Processor.Format.schema_format/2` for the default implementation. """ @callback schema_format(state :: State.t(), schema :: Schema.t()) :: Format.format() | :unknown @doc """ Choose the name of the module and type for the given schema Each module may contain multiple schemas with distinct types. By convention, the default type (used for single-schema modules) is `:t`, as in `MySchema.t()`. Additional schemas in the same module may represent variations on the default type, such as a `User.public()` schema containing a reduced set of fields from `User.t()`. See `OpenAPI.Processor.Naming.schema_module_and_type/2` for the default implementation. """ @callback schema_module_and_type(state :: State.t(), schema :: Schema.t()) :: {module, atom} # # Default Implementations # @doc false defdelegate ignore_operation?(state, operation_spec), to: OpenAPI.Processor.Ignore @doc false defdelegate ignore_schema?(state, schema_spec), to: OpenAPI.Processor.Ignore @doc false defdelegate operation_docstring(state, operation_spec, params), to: OpenAPI.Processor.Operation, as: :docstring @doc false defdelegate operation_function_name(state, operation_spec), to: OpenAPI.Processor.Naming, as: :operation_function @doc false defdelegate operation_module_names(state, operation_spec), to: OpenAPI.Processor.Naming, as: :operation_modules @doc false defdelegate operation_request_body(state, operation_spec), to: OpenAPI.Processor.Operation, as: :request_body @doc false defdelegate operation_request_method(state, operation_spec), to: OpenAPI.Processor.Operation, as: :request_method @doc false defdelegate operation_response_body(state, operation_spec), to: OpenAPI.Processor.Operation, as: :response_body @doc false defdelegate schema_format(state, schema), to: OpenAPI.Processor.Format @doc false defdelegate schema_module_and_type(state, schema_spec), to: OpenAPI.Processor.Naming # # Helpers # @methods [:get, :put, :post, :delete, :options, :head, :patch, :trace] @spec collect_operations_and_schemas(State.t()) :: State.t() defp collect_operations_and_schemas(state) do %State{implementation: implementation, spec: %Spec{paths: paths}} = state for {_path, item} <- paths, method <- @methods, operation_spec = Map.get(item, method), not implementation.ignore_operation?(state, operation_spec), reduce: state do state -> process_operation(state, operation_spec) end end @spec process_operation(State.t(), OperationSpec.t()) :: State.t() defp process_operation(state, operation_spec) do %State{implementation: implementation} = state %OperationSpec{ "$oag_path": request_path, "$oag_path_parameters": params_from_path, parameters: params_from_operation } = operation_spec {state, all_params} = (params_from_path ++ params_from_operation) |> Enum.reverse() |> Enum.reduce({state, []}, fn param, {state, params} -> {state, param} = Param.from_spec(state, param) {state, [param | params]} end) path_params = Enum.filter(all_params, &(&1.location == :path)) query_params = Enum.filter(all_params, &(&1.location == :query)) docstring = implementation.operation_docstring(state, operation_spec, query_params) module_names = implementation.operation_module_names(state, operation_spec) request_method = implementation.operation_request_method(state, operation_spec) for module_name <- module_names, reduce: state do state -> function_name = implementation.operation_function_name(state, operation_spec) {state, request_body} = implementation.operation_request_body(state, operation_spec) |> process_request_body(state, module_name, function_name) {state, response_body} = implementation.operation_response_body(state, operation_spec) |> process_response_body(state, module_name, function_name) operation = %Operation{ docstring: docstring, function_name: function_name, module_name: module_name, request_body: request_body, request_method: request_method, request_path: request_path, request_path_parameters: path_params, request_query_parameters: query_params, responses: response_body } %State{state | operations: [operation | state.operations]} end end @spec process_request_body(Operation.request_body_unprocessed(), State.t(), String.t(), atom) :: {State.t(), Operation.request_body()} defp process_request_body(request_body, state, op_module_name, op_function_name) do request_body |> Enum.sort_by(fn {content_type, _schema} -> content_type end, :desc) |> Enum.reduce({state, []}, fn {content_type, schema_spec}, {state, request_body} -> context = {:request, op_module_name, op_function_name, content_type} {state, type} = Type.from_schema(state, schema_spec) state = Type.reduce(type, state, fn t, state -> if is_reference(t) do schema = Map.fetch!(state.schema_specs_by_ref, t) process_schema(state, t, schema, context) else state end end) {state, [{content_type, type} | request_body]} end) end @spec process_response_body(Operation.response_body_unprocessed(), State.t(), String.t(), atom) :: {State.t(), Operation.response_body()} defp process_response_body(response_body, state, op_module_name, op_function_name) do response_body |> Enum.sort_by(fn {status_code, _schema} -> status_code end, :desc) |> Enum.reduce({state, []}, fn {status_code, schema_specs}, {state, response_body} -> {state, schema_types} = schema_specs |> Enum.reverse() |> Enum.reduce({state, %{}}, fn {content_type, schema_spec}, {state, schema_types} -> context = {:response, op_module_name, op_function_name, status_code, content_type} {state, type} = Type.from_schema(state, schema_spec) state = Type.reduce(type, state, fn t, state -> if is_reference(t) do schema = Map.fetch!(state.schema_specs_by_ref, t) process_schema(state, t, schema, context) else state end end) {state, Map.put(schema_types, content_type, type)} end) {state, [{status_code, schema_types} | response_body]} end) end @spec process_schema(State.t(), reference, SchemaSpec.t(), tuple, MapSet.t()) :: State.t() defp process_schema(state, ref, schema_spec, context, seen_refs \\ MapSet.new()) do schema_spec = SchemaSpec.add_context(schema_spec, context) if MapSet.member?(seen_refs, ref) do state else %State{implementation: implementation, schemas_by_ref: schemas_by_ref} = state seen_refs = MapSet.put(seen_refs, ref) cond do implementation.ignore_schema?(state, schema_spec) -> schema = Schema.map(ref) State.put_new_schema(state, ref, schema) schema = Map.get(schemas_by_ref, ref) -> schema = Schema.merge_contexts(schema, schema_spec) State.put_schema(state, ref, schema) :else -> {state, fields} = process_schema_fields(state, schema_spec, ref, seen_refs) schema = Schema.new(ref, schema_spec, fields) State.put_schema(state, ref, schema) end end end @spec process_schema_fields(State.t(), SchemaSpec.t(), reference, MapSet.t()) :: {State.t(), [Field.t()]} defp process_schema_fields(state, schema_spec, parent_ref, seen_refs) do %SchemaSpec{properties: properties, required: required} = schema_spec for {field_name, field_spec} <- properties, reduce: {state, []} do {state, fields} -> nullable? = case field_spec do %SchemaSpec{nullable: nullable?} -> nullable? {:ref, _} -> false end required? = is_list(required) and field_name in required context = {:field, parent_ref, field_name} {state, type} = Type.from_schema(state, field_spec) state = Type.reduce(type, state, fn t, state -> if is_reference(t) do schema = Map.fetch!(state.schema_specs_by_ref, t) process_schema(state, t, schema, context, seen_refs) else state end end) field = %Field{ name: field_name, nullable: nullable?, private: false, required: required?, type: type } {state, [field | fields]} end end @spec record_schema_output_format(State.t()) :: State.t() defp record_schema_output_format(state) do %State{schemas_by_ref: schemas_by_ref} = state to_process = schemas_by_ref |> Map.values() |> :queue.from_list() do_record_schema_output_format(state, to_process) end @spec do_record_schema_output_format(State.t(), :queue.queue(Schema.t())) :: State.t() defp do_record_schema_output_format(state, to_process) do %State{implementation: implementation} = state case :queue.out(to_process) do {{:value, schema}, to_process} -> case implementation.schema_format(state, schema) do :unknown -> to_process = :queue.in(schema, to_process) do_record_schema_output_format(state, to_process) format -> schema = %Schema{schema | output_format: format} state = State.put_schema(state, schema.ref, schema) do_record_schema_output_format(state, to_process) end {:empty, _to_process} -> state end end @spec record_schema_module_and_type(State.t()) :: State.t() defp record_schema_module_and_type(state) do %State{implementation: implementation, schemas_by_ref: schemas_by_ref} = state for {_ref, schema} <- schemas_by_ref, reduce: state do state -> {module, type} = implementation.schema_module_and_type(state, schema) schema = %Schema{schema | module_name: module, type_name: type} State.put_schema(state, schema.ref, schema) end end end