Created
July 10, 2025 21:28
-
-
Save lud/0b2eccb093bb4b8d1b0725011c714ec5 to your computer and use it in GitHub Desktop.
Oaskit/JSV demo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# :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