Oaskit Quickstart Guide

View Source

Welcome to Oaskit! This guide will walk you through setting up and using Oaskit to validate HTTP requests in your Phoenix application based on OpenAPI 3.1 specifications.

Installation

First, add Oaskit to your dependencies in mix.exs:

def deps do
  [
    {:oaskit, "~> 0.3"},
  ]
end

You can also add the AbnfParsec dependency to support more formats in the JSON schema format keyword.

Finally, you will most probably use macros from this library, import formatter rules in your .formatter.exs file:

# .formatter.exs
[
  import_deps: [:oaskit]
]

Creating an API Specification Module

Create a module that defines your OpenAPI specification using the Oaskit module. This module will serve as the central definition of your API:

defmodule MyAppWeb.ApiSpec do
  alias Oaskit.Spec.Paths
  alias Oaskit.Spec.Server
  use Oaskit

  @impl true
  def spec do
    %{
      openapi: "3.1.1",
      info: %{
        title: "My App API",
        version: "1.0.0",
        description: "Main HTTP API for My App"
      },
      servers: [Server.from_config(:my_app, MyAppWeb.Endpoint)],
      paths: Paths.from_router(MyAppWeb.Router, filter: &String.starts_with?(&1.path, "/api/"))
    }
  end
end

The Oaskit.Spec.Paths.from_router/2 function automatically extracts API paths from your Phoenix router, focusing only on controller actions that have operations defined. The optional :filter function lets you limit which routes are included in your specification.

Setting Up Router Pipelines

Configure your Phoenix router to use Oaskit validation with your API spec module by setting up a pipeline with the Oaskit.Plugs.SpecProvider plug:

defmodule MyAppWeb.Router do
  use Phoenix.Router

  # Define a pipeline for API routes with Oaskit validation
  pipeline :api do
    plug :accepts, ["json"]
    plug Oaskit.Plugs.SpecProvider, spec: MyAppWeb.ApiSpec
  end

  # Apply the pipeline to your API routes
  scope "/api", MyAppWeb do
    pipe_through :api

    resources "/users", UserController, only: [:index, :show, :create]
    resources "/posts", PostController, only: [:index, :show, :create, :update]
  end
end

The Oaskit.Plugs.SpecProvider plug is required as a controller action and its defined OpenAPI operation can be referenced in multiple specifications.

Configuring Controllers

Set up your controllers to use Oaskit validation. You can do this globally in your MyAppWeb module:

defmodule MyAppWeb do
  def controller do
    quote do
      use Phoenix.Controller,
        formats: [:html, :json],
        layouts: [html: MyAppWeb.Layouts]

      # Add Oaskit controller macros
      use Oaskit.Controller

      # Add the validation plug
      plug Oaskit.Plugs.ValidateRequest

      import Plug.Conn
      use Gettext, backend: MyAppWeb.Gettext

      unquote(verified_routes())
    end
  end

  # ...
end

This setup ensures all controllers using use MyAppWeb, :controller will have Oaskit validation enabled. See the documentation of Oaskit.Plugs.ValidateRequest to configure the plug globally for only a subset of your controllers.

Defining Operations in Controllers

Now you can define operations in your controllers using the Oaskit.Controller.operation/2 macro.

To import the macros, use your :controller helper as usual since you've added use Oaskit.Controller in the previous step.

defmodule MyAppWeb.UserController do
  use MyAppWeb, :controller

  # ...
end

The operation macro takes the function name and the operation specs as arguments.

In this example we use syntax shortcuts to refer directly to schemas, but the operation macro lets you define responses or requests with multiple content types, custom descriptions and many other options. Make sure to read the docs!

operation :create,
  summary: "Create a new user",
  request_body: {
    %{
      type: :object,
      properties: %{
        name: %{type: :string, minLength: 1},
        email: %{type: :string, format: :email},
        age: %{type: :integer, minimum: 18}
      },
      required: [:name, :email]
    },
    description: "The user payload"
  },
  parameters: [
    source: [in: :query, schema: %{type: :string}, required: false]
  ],
  responses: [
    created: UserSchema,
    unprocessable_entity: ErrorSchema
  ]

def create(conn, _params) do
  # The `_params` variable from phoenix is not changed by the validation plug.
  #
  # Validated and cast data is stored in `conn.private.oaskit`.
  #
  # You may explore what's in there (or read the docs), or you may use the
  # various helpers from the `Oaskit.Controller` module:
  user_data = body_params(conn)
  source = query_param(conn, :source)

  case create_user(user_data, source) do
    # ...
  end
end

Defining JSON Schemas

There are two ways to provide schemas in the various macros, either inline or using modules.

Inline schemas

Inline schemas are maps (with atoms or binary keys and values) or booleans.

@user_schema %{
  type: :object,
  title: "User",
  properties: %{
    name: %{type: :string, minLength: 1},
    email: %{type: :string, format: :email},
    age: %{type: :integer, minimum: 0}
  },
  required: [:name, :email]
}

operation :create,
  request_body: {@user_schema, [required: true]},
  responses: [ok: {@user_schema, []}]

While they are practical, such maps are duplicated in the compiled module as well as in the OpenAPI specification document.

Module-based schemas

A module-based schema is any module that exports a schema/0 function returning a valid JSON schema.

Oaskit uses JSV. Module-based schemas defined with JSV.defschema/1 are automatically cast to structs when validation succeeds, making them convenient to work with, notably thanks to the new Elixir types compiler!

Make sure to check the JSV documentation for additional features.

defmodule MyAppWeb.Schemas.UserSchema do
  use JSV.Schema

  defschema %{
    type: :object,
    title: "User",
    properties: %{
      id: %{type: :integer},
      name: %{type: :string, minLength: 1},
      email: %{type: :string, format: :email},
      age: %{type: :integer, minimum: 18}
    },
    required: [:id, :name, :email]
  }
end

# Use the module directly in operations in place of a schema
operation :show,
  responses: [ok: MyAppWeb.Schemas.UserSchema]

Such schemas are collected into the #/components/schemas section of the OpenAPI specification, which makes that document shorter, easier to navigate, and limits the memory usage at runtime.

Shared tags and parameters

Oaskit provides the Oaskit.Controller.tags/1 and Oaskit.Controller.parameter/2 macros for shared elements between operations.

Those macros only apply to operations defined after them.

# Add tags to group operations in documentation
tags ["users", "public"]

# Define parameters once for multiple operations
parameter :page, in: :query, schema: %{type: :integer}
parameter :per_page, in: :query, schema: %{type: :integer}

Testing with Oaskit.Test

The valid_response/3 helper validates that the response matches your OpenAPI specification, including status code, content type, and response body schema. It returns the parsed response data for further assertions.

defmodule MyAppWeb.UserControllerTest do
  use MyAppWeb.ConnCase

  # Helper to wrap Oaskit.Test.valid_response/3
  # Feel free to add it directly into your ConnCase module!
  defp valid_response(conn, status) do
    Oaskit.Test.valid_response(MyAppWeb.ApiSpec, conn, status)
  end

  test "create user with valid data", %{conn: conn} do
    user_params = %{
      name: "John Doe",
      email: "john@example.com",
      age: 25
    }

    conn = post(conn, ~p"/api/users", user_params)

    # Validate the response against your OpenAPI specification. It returns
    # decoded data for JSON content-types.
    assert %{
             "name" => "John Doe",
             "email" => "john@example.com",
             "age" => 25
           } =
             valid_response(conn, 201)
  end

  test "create user with invalid data returns validation errors", %{conn: conn} do
    invalid_params = %{
      name: "",
      email: "invalid-email",
      age: 15
    }

    conn = post(conn, ~p"/api/users", invalid_params)

    # You can use `valid_response` if you define a response schema for the
    # errors. See `Oaskit.ErrorHandler.Default.error_response_schema/0`.
    #
    # valid_response(conn, 422)

    # If you do not declare all possible responses, using the good old
    # `Phoenix.ConnTest.json_response/2` works fine!
    assert json_response(conn, 422)
  end
end

Generating OpenAPI Documentation

Once you have your operations defined, you can generate an OpenAPI specification file using the Mix task:

mix openapi.dump MyAppWeb.ApiSpec --pretty -o priv/openapi.json

This generates a complete OpenAPI 3.1 specification file that can be used with various tools like client generators for TypeScript or Elixir.

Serving OpenAPI Specifications

You can serve your OpenAPI specification dynamically using the Oaskit.SpecController. This controller serves your specification as JSON at an HTTP endpoint.

Add the controller to your router:

get "/openapi.json", Oaskit.SpecController, spec: MyAppWeb.ApiSpec

This will serve your OpenAPI specification at /openapi.json. Pass ?pretty=1 to get pretty printed JSON.

You can also use that controller to serve Redoc UI. Pass the full URL path to the json route you just defined:

get "/docs", Oaskit.SpecController, redoc: "/openapi.json"

Redoc allows you to browse your API endpoints, view request/response schemas, and see examples, but note that it is read-only and doesn't allow testing the API directly.

Ask for help!

If anything is unclear, or if you would like to see more features, plase fill an issue in the Github repository.

Happy coding!