REST: Stoic API
En este proyecto basado en el tutorial Simple Rest API with Phoenix se creará una API Rest para devolver citas de filósofos estóicos.
Paso 1: Crear el proyecto y la base de datos
Se utilizará SQLite3 para simplificar la configuración.
Primero creamos un nuevo proyecto dando el parámetro de --database=sqlite3
ya que
el valor predeterminado es Postgres.
También se creará la base de datos con el comando ecto.create
.
$ mix phx.new stoic_quotes --database=sqlite3
$ cd stoic_quotes
$ mix ecto.create
Paso 2: Crear las migraciones
Una migración es un control de versiones para la base de datos que define cambios en su esquema (crear, modificar o eliminar tablas, columnas, llaves primarias, relaciones e índices) en archivos de código Elixir, en lugar de usar archivos SQL tradicionales. Esta herramienta permite mantener la base de datos sincronizada con la aplicación, facilita la colaboración en equipo al evitar conflictos y permite desplegar cambios de forma sencilla en producción.
El comando que proporcionada Phoenix para simplificar la creación de los esquemas y migraciones
es phx.gen.context <nombre de tabla en el esquema (plural)> <recurso (singular)> <nombre de archivo> <attributos>:<tipo>
.
Esto es una herramienta llamada "generador" (puede encontrar más acá)
y sirven para simplificar tareas comunes y recurrentes como crear recursos.
La tabla a crear es la siguiente:
$ mix phx.gen.context Quotes Quote quotes quote:text author:string source:string
El comando debío crear la siguiente migración ubicada en priv/repo/migrations/*_create_quotes.exs
:
defmodule StoicQuotes.Repo.Migrations.CreateQuotes do
use Ecto.Migration
def change do
create table(:quotes) do
add :quote, :text
add :author, :string
add :source, :string
timestamps(type: :utc_datetime)
end
end
end
Y también un esquema ubicado en lib/stoic_quotes/quotes/quote.ex
.
Notemos la función changeset,
la cual realiza las validaciones antes de permitir insertar un nuevo registro
en la base de datos.
defmodule StoicQuotes.Quotes.Quote do
use Ecto.Schema
import Ecto.Changeset
schema "quotes" do
field :quote, :string
field :author, :string
field :source, :string
timestamps(type: :utc_datetime)
end
@doc false
def changeset(quote, attrs) do
quote
|> cast(attrs, [:quote, :author, :source])
|> validate_required([:quote, :author, :source])
end
end
Adicionalmente se puede mejorar el changeset ya que vemos que se repite los campos
[:quote, :author, :source]
. Para esto utilizaremos un attributo especial llamado schema(:fields)
dentro del módulo MODULE
.
Por lo que podremos utilizarlo en las funciones cast
y validate_required
.
Para esto definimos una nueva función que lo retorne:
def fields() do
__MODULE__.__schema__(:fields)
end
Además necesitamos definir un atributo que permita saber que campos son opcionales.
Para esto realizaremos la operación fields() — @optional_fields
para obtener todos
los campos requeridos.
@optional_fields [:id, :inserted_at, :updated_at]
def required_fields() do
fields() -- @optional_fields
end
Finalmente el archivo quedará como lo siguiente:
defmodule StoicQuotes.Quotes.Quote do
use Ecto.Schema
import Ecto.Changeset
@optional_fields [:id, :inserted_at, :updated_at]
schema "quotes" do
field :quote, :string
field :author, :string
field :source, :string
timestamps(type: :utc_datetime)
end
def fields() do
__MODULE__.__schema__(:fields)
end
def required_fields() do
fields() -- @optional_fields
end
@doc false
def changeset(quote, attrs) do
quote
|> cast(attrs, fields())
|> validate_required(required_fields())
end
end
El esquema es utilizado por el contexto de Phoenix, también conocido
como dominio (en Domain Driven Desing), modelo (en MVC) o reglas de negocio (en capas). Es un archivo utilizado como capa
superior al esquema y que permite realizar operaciones comunes como consultas, modificaciones o inserciones.
Esta ubicado en lib/stoic_quotes/quotes.ex
. Si bien tiene varias funciones, es recomendable subdividir
este contexto en archivos con operaciones de lectura y escritura para evitar que se convierta en un archivo demasiado grande,
esto se llama Command - Query Responsability Segregation (CQRS), segregación de la responsabilidad entre comandos y consultas.
quotes/
quotes.ex (bussiness logic)
quote.ex (schema)
commands.ex (insert, update, delete)
queries.ex (select)
Para fines pedagógicos solamente nos enfocaremos en un archivo común.
defmodule StoicQuotes.Quotes do
@moduledoc """
The Quotes context.
"""
import Ecto.Query, warn: false
alias StoicQuotes.Repo
alias StoicQuotes.Quotes.Quote
@doc """
Returns the list of quotes.
## Examples
iex> list_quotes()
[%Quote{}, ...]
"""
def list_quotes do
Repo.all(Quote)
end
@doc """
Gets a single quote.
Raises `Ecto.NoResultsError` if the Quote does not exist.
## Examples
iex> get_quote!(123)
%Quote{}
iex> get_quote!(456)
** (Ecto.NoResultsError)
"""
def get_quote!(id), do: Repo.get!(Quote, id)
@doc """
Creates a quote.
## Examples
iex> create_quote(%{field: value})
{:ok, %Quote{}}
iex> create_quote(%{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def create_quote(attrs) do
%Quote{}
|> Quote.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a quote.
## Examples
iex> update_quote(quote, %{field: new_value})
{:ok, %Quote{}}
iex> update_quote(quote, %{field: bad_value})
{:error, %Ecto.Changeset{}}
"""
def update_quote(%Quote{} = quote, attrs) do
quote
|> Quote.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a quote.
## Examples
iex> delete_quote(quote)
{:ok, %Quote{}}
iex> delete_quote(quote)
{:error, %Ecto.Changeset{}}
"""
def delete_quote(%Quote{} = quote) do
Repo.delete(quote)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking quote changes.
## Examples
iex> change_quote(quote)
%Ecto.Changeset{data: %Quote{}}
"""
def change_quote(%Quote{} = quote, attrs \\ %{}) do
Quote.changeset(quote, attrs)
end
end
La principal diferencia entre la migración y el esquema, es que la migración puede cambiar y esta íntimamente ligada a la estructura de la base de datos. El esquema es una capa que puede mantenerse en el tiempo y no necesariamente tenga una migración asociada, aunque es recomendable que ambos estén actualizados. La migración solo se utiliza al momento de ejecutar cambios en la base de datos por consola y con la aplicación apagada. El esquema se puede utilizar durante la ejecución de la aplicación para almacenar, consultar y modificar los registros en la base de datos.
Paso 3: Crear las llaves primarias e índices
Ahora se darán restricciones a la base de datos para evitar datos duplicados y mejorar la velocidad de lectura y consultas al tener índices.
Por defecto, cuando defines un esquema de Ecto sin especificar explícitamente una clave primaria, Ecto asume una columna id de tipo :bigserial (o el equivalente para tu base de datos) que se incrementa automáticamente y es única.
La línea timestamps(type: :utc_datetime)
se encarga de crear automáticamente los campos inserted_at
y updated_at
.
Para añadir un índice de valor único añadimos al final de nuestra migración lo siguiente:
create unique_index(:quotes, [:quote], name: :index_for_duplicate_quotes)
Quedando el archivo como sigue
defmodule StoicQuotes.Repo.Migrations.CreateQuotes do
use Ecto.Migration
def change do
create table(:quotes) do
add :quote, :text
add :author, :string
add :source, :string
timestamps(type: :utc_datetime)
end
create unique_index(:quotes, [:quote], name: :index_for_duplicate_quotes)
end
end
También actualizamos el esquema para reflejar este nuevo índice y restricción.
Añadiendo las siguientes línea al esquema (lib/stoic_quotes/quotes/quote.ex
).
|> unsafe_validate_unique(:quote, StoicQuotes.Repo)
|> unique_constraint(:quote)
-
unsafe_validate_unique(:quote, StoicQuotes.Repo)
: Valida de forma rápida consultando a la base de datos, ideal para los formularios. Puede tener condiciones de carrera por lo que solo es recomendable para validaciones rápidas. -
unique_constraint(:quote)
: Intenta agregar un nuevo registro a la base de datos y arroja un error si la base de datos lo impide. Simplemente estamos diciendo a Ecto que el campoquote
tiene una restricción de valor único y que debe considerar el error de la bd como parte de las validaciones.
Quedando como sigue
defmodule StoicQuotes.Quotes.Quote do
use Ecto.Schema
import Ecto.Changeset
@optional_fields [:id, :inserted_at, :updated_at]
schema "quotes" do
field(:quote, :string)
field(:author, :string)
field(:source, :string)
timestamps(type: :utc_datetime)
end
def fields() do
__MODULE__.__schema__(:fields)
end
def required_fields() do
fields() -- @optional_fields
end
@doc false
def changeset(quote, attrs) do
quote
|> cast(attrs, fields())
|> validate_required(required_fields())
|> unsafe_validate_unique(:quote, StoicQuotes.Repo)
|> unique_constraint(:quote)
end
end
Ahora simplemente ejecutamos la migración para crear las tablas en la base de datos.
$ mix ecto.migrate
Debería crear una nueva tabla, la cual podemos verificar con un gestor de base de datos como DBeaver.

Paso 4: Añadir registros a la base de datos
Tenemos un pequeño archivo json
que tiene los datos necesarios.
Creamos un archivo llamado quotes
dentro de priv/repo
.
[
{
"quote": "Seldom are any found unhappy from not observing what is in the minds of others. But such as observe not well the stirrings of their own souls must of necessity be unhappy.",
"author": "Marcus Aurelius",
"source": "Book II, Meditations"
},
{
"quote": "Consider whence each thing came, of what it was compounded, into what it will be changed, how it will be with it when changed, and that it will suffer no evil.",
"author": "Marcus Aurelius",
"source": "Book XI, Meditations"
},
{
"quote": "Accustom yourself as much as possible, when any one takes any action, to consider only: To what end is he working? But begin at home; and examine yourself first of all.",
"author": "Marcus Aurelius",
"source": "Book X, Meditations"
}
]
Ahora es necesario crear las "semillas" o "seeds" que iniciarán los valores que nuestra base de datos necesita. Este script solo es recomendable ejecutar cuando se inicia la base de datos, sobre todo para establecer el ambiente de desarrollo y que tenga los datos necesarios para que la aplicación funcione.
Editamos el archivo priv/repo/seeds.exs
alias StoicQuotes.Quotes
# Read quotes from the JSON file
quotes_path = "priv/repo/quotes.json"
quotes_path
|> File.read!()
|> Jason.decode!()
|> Enum.each(fn attrs ->
quote = %{quote: attrs["quote"], author: attrs["author"], source: attrs["source"]}
case Quotes.create_quote(quote) do
{:ok, _quote} -> :ok
{:error, _changeset} -> :duplicate
end
end)
Y ejecutamos el comando
$ mix run priv/repo/seeds.exs
Los comandos de |
Si todo sale bien podremos verificar en DBeaver que los datos están presentes.

Si se quiere verificar por consola también se puede ejecutar el siguiente comando:
$ iex -S mix
$ StoicQuotes.Quotes.list_quotes()
Paso 5: Construcción de Rutas
Esta API tendrá dos rutas principales:
-
/api/quotes/
: Lista todas las citas disponibles. -
/api/quotes/random
: Lista una cita aleatoria.
Para esto se debe editar el router
ubicado en lib/stoic_quotes_web/router.ex
.
scope "/api", StoicQuotesWeb do
pipe_through :api
get "/quotes", QuotesController, :index
get "/quotes/random", QuotesController, :show
end
El siguiente código nos indica lo siguiente:
-
scope
: Es una macro que acepta como parámetro la ruta base (endpoint) y el módulo base para buscar los controladores.
# las rutas tendrán como base /api y como base el módulo StoicQuotesWeb
scope "/api", StoicQuotesWeb do
El siguiente código nos indica lo siguiente:
-
pipe_through
: Es una macro que gatillará lo definido en el pipeline:api
para todos los requests que cumplan elscope "/api"
.
pipe_through :api
El pipeline de :api
establece un pipeline para aceptar requests del formato json
,
se define como lo siguiente:
pipeline :api do
plug(:accepts, ["json"])
end
El código nos indica lo siguiente:
-
get
: Es la función identificada con el verbo HTTP a usar en la ruta. Por ejemplo si usamos POST no encontrará la ruta. -
/quotes
: Es la ruta donde deberemos hacer las llamadas HTTP. Como estamos dentro del scope/api/
la ruta completa será/api/quotes
-
QuotesController
: Es el módulo donde se encontrarán las funciones para procesar el request. Como estamos dentro del scopeStoicQuotesWeb
el módulo usado seráStoicQuotesWeb.QuotesController
. -
:index
: Es un átomo que permite identificar el request, utilizado en el módulo para segregar las funcionalidad de manejar el request. En este caso se asociará a una función dentro del controlador llamadaindex
.
# verbo http, "/ruta", modulo, parámetro
get "/quotes", QuotesController, :index
Quedando el archivo como lo siguiente:
defmodule StoicQuotesWeb.Router do
use StoicQuotesWeb, :router
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
plug(:fetch_live_flash)
plug(:put_root_layout, html: {StoicQuotesWeb.Layouts, :root})
plug(:protect_from_forgery)
plug(:put_secure_browser_headers)
end
pipeline :api do
plug(:accepts, ["json"])
end
scope "/", StoicQuotesWeb do
pipe_through(:browser)
get("/", PageController, :home)
end
scope "/api", StoicQuotesWeb do
pipe_through(:api)
get("/quotes", QuotesController, :index)
get("/quotes/random", QuotesController, :show)
end
# Enable LiveDashboard and Swoosh mailbox preview in development
if Application.compile_env(:stoic_quotes, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
# If your application does not have an admins-only section yet,
# you can use Plug.BasicAuth to set up some basic authentication
# as long as you are also using SSL (which you should anyway).
import Phoenix.LiveDashboard.Router
scope "/dev" do
pipe_through(:browser)
live_dashboard("/dashboard", metrics: StoicQuotesWeb.Telemetry)
forward("/mailbox", Plug.Swoosh.MailboxPreview)
end
end
end
Paso 6: Crear el controlador
El controlador es donde se alojan las funciones que responderán a las requests definidas en el router.
Por lo que se debe crear un nuevo archivo llamado quotes_controller.ex
dentro de stoic_quotes_web/controllers/quotes_controller.ex
y tener el siguiente contenido:
defmodule StoicQuotesWeb.QuotesController do
use Phoenix.Controller, formats: [:json]
alias StoicQuotes.Quotes
def index(conn, _params) do
quotes = %{quotes: Quotes.list_quotes()}
render(conn, :index, quotes)
end
def show(conn, _params) do
quote = %{quote: Quotes.get_random_quote()}
render(conn, :show, quote)
end
end
-
def index(conn, _params)
: Notar como cada función recibe un parámetro conexión (conn), donde tiene los detalles del request, el cual se usará para ser enviado a otras funciones comorender
y el resto de parámetros (params) donde se reciben los distintos parámetros definidos en la ruta principal. -
use Phoenix.Controller, formats: [:json]
: Define a este módulo como un controlador que responde conjson
. -
render(conn, :index, quotes)
: Utiliza la función render que llama a la vista y genera el json final pasándole los parámetros desde el controlador.
Paso 7: Crear la vista
La vista será principalmente un json
, por lo que tenemos que crear un nuevo archivo llamado quotes_json.ex
dentro del mismo directorio que quotes_controller.ex
.
Notar que tiene las mismas funciones usadas en el controlador, con la excepción de que definen su parámetro
como el dato a mostrar, que es pasado a la función render
usada en el controlador.
defmodule StoicQuotesWeb.QuotesJSON do
alias StoicQuotes.Quotes.Quote
def index(%{quotes: quotes}) do
%{data: for(quote <- quotes, do: data(quote))}
end
def show(%{quote: quote}) do
%{data: data(quote)}
end
defp data(%Quote{} = datum) do
%{
quote: datum.quote,
author: datum.author,
source: datum.source
}
end
end
Paso 8: Modificar el contexto
Debemos modificar el contexto (o modelo) para añadir la función Quotes.get_random_quote()
usada en el controlador
en su función show
.
@doc """
Gets a random quote
## Examples
iex> get_random_quote()
%Quote{}
"""
def get_random_quote() do
query =
from(q in Quote,
order_by: fragment("RANDOM()"),
limit: 1
)
Repo.one(query)
end
Paso 9: Pruebas Manuales
Para las pruebas se puede usar curl
o crear una colección con Bruno.
Las pruebas automatizadas con mix test
serán implementadas en un proyecto futuro.
$ iex -S mix phx.server
$ curl -i localhost:4000/api/quotes/
$ curl -i localhost:4000/api/quotes/random
➜ ~ curl -i localhost:4000/api/quotes/
HTTP/1.1 200 OK
date: Sat, 27 Sep 2025 03:16:50 GMT
content-length: 723
vary: accept-encoding
content-type: application/json; charset=utf-8
cache-control: max-age=0, private, must-revalidate
x-request-id: GGkFWTujtWGncGAAAAGC
{"data":[{"author":"Marcus Aurelius","source":"Book II, Meditations","quote":"Seldom are any found unhappy from not observing what is in the minds of others. But such as observe not well the stirrings of their own souls must of necessity be unhappy."},{"author":"Marcus Aurelius","source":"Book XI, Meditations","quote":"Consider whence each thing came, of what it was compounded, into what it will be changed, how it will be with it when changed, and that it will suffer no evil."},{"author":"Marcus Aurelius","source":"Book X, Meditations","quote":"Accustom yourself as much as possible, when any one takes any action, to consider only: To what end is he working? But begin at home; and examine yourself first of all."}]}%