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:

Diagram
$ 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.

lib/stoic_quotes/quotes/quote.ex
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:

lib/stoic_quotes/quotes/quote.ex
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 campo quote 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.

tables

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.

priv/repo/quotes.json
[
  {
    "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 mix deben ser ejecutados en el mismo lugar donde esta presente el archivo mix.exs. Podemos verificar usando los comandos ls y pwd.

Si todo sale bien podremos verificar en DBeaver que los datos están presentes.

seeds

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 el scope "/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 scope StoicQuotesWeb 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 llamada index.

# verbo http, "/ruta", modulo, parámetro
get "/quotes", QuotesController, :index

Quedando el archivo como lo siguiente:

stoic_quotes_web/router.ex
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:

stoic_quotes_web/controllers/quotes_controller.ex
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 como render 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 con json.

  • 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.

lib/stoic_quotes/quotes.ex
@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."}]}%

Paso 10: Siguientes Pasos

Ahora se tiene una API Rest. En el siguiente tutorial se verá como crear un formulario que permita añadir desde la web una nueva cita estóica utilizando LiveView.