Skip to content

Instantly share code, notes, and snippets.

@lud
Created July 10, 2025 21:28
Show Gist options
  • Save lud/0b2eccb093bb4b8d1b0725011c714ec5 to your computer and use it in GitHub Desktop.
Save lud/0b2eccb093bb4b8d1b0725011c714ec5 to your computer and use it in GitHub Desktop.
Oaskit/JSV demo
# :jsv is automatically pulled with :oaskit but since we are going to use it,
# let's be explicit about the deps!
Mix.install(
[
{:oaskit, "~> 0.3"},
{:jsv, "~> 0.10"},
{:plug_cowboy, "~> 2.5"},
{:jason, "~> 1.0"},
{:phoenix, "~> 1.8.0-rc.3"}
],
consolidate_protocols: false
)
# Configure phoenix so the endpoint can be started. The :url config will be used
# in our API specification.
Application.put_env(
:demo,
Demo.Endpoint,
[
http: [ip: {127, 0, 0, 1}, port: 5000],
url: [host: "localhost", port: 5000],
server: true,
secret_key_base: Base.encode64(:crypto.strong_rand_bytes(48))
],
persistent: true
)
# Let's start with the router. At the router level, in the pipeline definitions
# we need to inject an API specification module with
# `Oaskit.Plugs.SpecProvider`. This is required since operations can belong to
# multiple APIs. The API to use must be attached to the route and not the
# controller.
defmodule Demo.Router do
use Phoenix.Router
pipeline :api do
plug :accepts, ["json"]
plug Oaskit.Plugs.SpecProvider, spec: Demo.ApiSpec
end
scope "/potions", Demo do
pipe_through :api
post "/", PotionController, :create_potion
end
end
# The endpoint is very classic. In this demo we only use JSON data so it's
# minimal.
defmodule Demo.Endpoint do
use Phoenix.Endpoint, otp_app: :demo
plug Plug.Parsers,
parsers: [:json],
pass: ["*/*"],
json_decoder: Jason
plug Demo.Router
end
# A bunch of schemas defined with `JSV.defschema/2` to represent schemas for our
# domain. We use the shortform syntax of JSV instead of using full map
# definitions as schemas.
defmodule Demo.Schemas do
use JSV.Schema
defschema Ingredient,
name: string(),
quantity: integer(),
unit: enum(["pinch", "dash", "scoop", "whiff", "nub"])
defschema Potion,
id: integer(),
name: string(),
ingredients: array_of(Ingredient)
end
# In the controller we need to pull a bunch of helpers. This will generatlly be
# donne in the controller function of "MyAppWeb" or equivalent, so it's laid out
# only once for all api controllers.
#
# In this demo we write the code direclty. Here are the moving parts:
#
# * `use JSV.Schema` - to be able to define schemas directly in the controller.
# Some people prefer it that way, as those schemas are only used in that
# controller. Other people would rather put all the schemas in dedicated
# files. In any case, it's possible to do it there.
#
# * `use Oaskit.Controller` - this pulls the `operation` macro and will allow
# the spec module and the test helpers to fetch the operations. It will also
# bring some helpers like `body_params/1`.
#
# * `plug Oaskit.Plugs.ValidateRequest` - this will validate incoming requests
# (path and query parameters, body)
defmodule Demo.PotionController do
alias Demo.Schemas.Ingredient
alias Demo.Schemas.Potion
import Plug.Conn
use JSV.Schema
use Oaskit.Controller
use Phoenix.Controller, formats: [:json], layouts: []
plug Oaskit.Plugs.ValidateRequest
defschema PotionCreationParams,
name: string(),
ingredients: array_of(Ingredient)
defschema PotionResponse,
data: Potion
operation :create_potion,
operation_id: "CreatePotion",
request_body: PotionCreationParams,
responses: [created: PotionResponse]
def create_potion(conn, _params) do
# The validated request body is defined in
# `conn.private.oaskit.body_params`. Generally it's better to use the
# provided helper (there are some for parameters too) if we need to change
# the storage location for the validated elements.
%PotionCreationParams{name: name, ingredients: ingredients} = body_params(conn)
potion = %{id: 123, name: name, ingredients: ingredients}
conn
|> put_status(201)
|> json(%{data: potion})
# Or you could do :
#
# response = %PotionResponse{data: %Potion{id: 123, name: name, ingredients: ingredients}}
#
# conn
# |> put_status(201)
# |> json(response)
#
# But it's JSON data, there is no need to return structs corresponding to
# the response schemas.
end
end
# For all of the above to work, we need an API specification. It can be created
# from the router.
#
# The server config can be read from the config (which does not need to start
# the endpoint). But it's simpler to hardcode it sometimes.
defmodule Demo.ApiSpec do
alias Oaskit.Spec.Paths
alias Oaskit.Spec.Server
use Oaskit
@moduledoc false
@impl true
def spec do
%{
openapi: "3.1.1",
info: %{title: "Demo API", version: "1.0.0"},
paths: Paths.from_router(Demo.Router),
servers: [Server.from_config(:demo, Demo.Endpoint)]
}
end
end
# We can test our endpoints with test helpers. Let's create a test first. We
# will `import Oaskit.Test` to get `valid_response/3`.
#
# In general, it's simpler to create a function in your ConnCase module to
# hardcode the api spec module:
#
# def valid_response(conn, status) do
# Oaskit.Test.valid_response(Demo.ApiSpec, conn, status)
# end
#
# But using the arity-3 function is fine too.
ExUnit.start(autorun: false)
defmodule Demo.PotionControllerTest do
import Phoenix.ConnTest
import Plug.Conn
import Oaskit.Test
use ExUnit.Case
@endpoint Demo.Endpoint
test "can create a potion" do
conn =
build_conn()
|> put_req_header("content-type", "application/json")
|> put_req_header("accept", "application/json")
payload = %{
name: "Beam Tonic",
ingredients: [
%{name: "Phoenix Feather", quantity: 1, unit: "whiff"},
%{name: "Beam Dust", quantity: 3, unit: "pinch"},
%{name: "Hex Crystal", quantity: 1, unit: "nub"}
]
}
conn = post(conn, "/potions", payload)
# When validating the response, as it's a JSON based operation, the data is
# automatically parsed.
#
# The function will validate the response payload according to the JSON
# schema defined for the given status code in the operation, if any.
assert %{"data" => %{"id" => _}} = valid_response(Demo.ApiSpec, conn, 201)
end
end
# This is a script, so you should be able to run it on your machine if you want
# to try stuff.
{:ok, _} = Supervisor.start_link([Demo.Endpoint], strategy: :one_for_one)
ExUnit.run([Demo.PotionControllerTest])
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment