├── test ├── support │ ├── repo.ex │ ├── schemas.ex │ └── test_case.ex ├── test_helper.exs └── jsonapi │ ├── sort_test.exs │ ├── include_test.exs │ ├── filter_test.exs │ └── page_test.exs ├── priv └── repo │ └── migrations │ └── 20170325232744_users.exs ├── .travis.yml ├── config ├── test.exs └── config.exs ├── .gitignore ├── lib └── inquisitor │ ├── jsonapi.ex │ └── jsonapi │ ├── sort.ex │ ├── filter.ex │ ├── include.ex │ └── page.ex ├── pages └── Default Query Scopes.md ├── mix.exs ├── mix.lock └── README.md /test/support/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Repo do 2 | use Ecto.Repo, otp_app: :inquisitor_jsonapi 3 | end 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Logger.configure(level: :info) 2 | ExUnit.start() 3 | 4 | {:ok, _pid} = Repo.start_link() 5 | -------------------------------------------------------------------------------- /test/support/schemas.ex: -------------------------------------------------------------------------------- 1 | defmodule User do 2 | use Ecto.Schema 3 | 4 | schema "users" do 5 | field :name 6 | field :age, :integer 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /priv/repo/migrations/20170325232744_users.exs: -------------------------------------------------------------------------------- 1 | defmodule Repo.Migrations.Users do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:users) do 6 | add :name, :string 7 | add :age, :integer 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.3.0 4 | - 1.4.0 5 | otp_release: 6 | - 18.0 7 | sudo: false # to use faster container based build environment 8 | cache: 9 | directories: 10 | - _build 11 | - deps 12 | before_script: 13 | - MIX_ENV=test mix do ecto.drop, ecto.create, ecto.migrate 14 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :inquisitor_jsonapi, ecto_repos: [Repo] 4 | config :inquisitor_jsonapi, Repo, 5 | adapter: Ecto.Adapters.Postgres, 6 | username: "postgres", 7 | password: "postgres", 8 | database: "inquisitor_jsonapi_test", 9 | pool: Ecto.Adapters.SQL.Sandbox, 10 | size: 1 11 | -------------------------------------------------------------------------------- /test/support/test_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Inquisitor.JsonApi.TestCase do 2 | defmacro __using__(_opts) do 3 | quote do 4 | use ExUnit.Case 5 | import Inquisitor.JsonApi.TestCase 6 | 7 | setup do 8 | Ecto.Adapters.SQL.Sandbox.mode(Repo, :manual) 9 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo) 10 | end 11 | end 12 | end 13 | 14 | def to_sql(query) do 15 | Ecto.Adapters.SQL.to_sql(:all, Repo, query) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /lib/inquisitor/jsonapi.ex: -------------------------------------------------------------------------------- 1 | defmodule Inquisitor.JsonApi do 2 | @moduledoc """ 3 | Container module for all Inquisitor.JsonApi modules 4 | 5 | `use` this module to opt-in to all modules: 6 | 7 | Before: 8 | 9 | defmodule MyApp do 10 | use Inquisitor 11 | use Inquisitor.JsonApi.Filter 12 | use Inquisitor.JsonApi.Include 13 | use Inquisitor.JsonApi.Page 14 | use Inquisitor.JsonApi.Sort 15 | 16 | ... 17 | end 18 | 19 | After: 20 | 21 | defmodule MyApp do 22 | use Inquisitor.JsonApi 23 | 24 | ... 25 | end 26 | """ 27 | defmacro __using__(_opts) do 28 | quote do 29 | use Inquisitor 30 | use Inquisitor.JsonApi.Filter 31 | use Inquisitor.JsonApi.Include 32 | use Inquisitor.JsonApi.Page 33 | use Inquisitor.JsonApi.Sort 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/inquisitor/jsonapi/sort.ex: -------------------------------------------------------------------------------- 1 | defmodule Inquisitor.JsonApi.Sort do 2 | @moduledoc """ 3 | Inquisitor query handlers for JSON API sorting 4 | 5 | [JSON API Spec](http://jsonapi.org/format/#fetching-sorting) 6 | 7 | #### Usage 8 | 9 | `use` the module *after* the `Inquisitor` module: 10 | 11 | defmodule MyApp do 12 | use Inquisitor 13 | use Inquisitor.JsonApi.Sort 14 | 15 | ... 16 | end 17 | """ 18 | require Inquisitor 19 | 20 | defmacro __using__(_opts) do 21 | quote do 22 | def build_query(query, "sort", sorts, _context) do 23 | sorts = 24 | sorts 25 | |> String.split(",") 26 | |> Enum.map(fn 27 | <<"-", column::binary>> -> {:desc, String.to_existing_atom(column)} 28 | column -> {:asc, String.to_existing_atom(column)} 29 | end) 30 | 31 | Ecto.Query.order_by(query, ^sorts) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /test/jsonapi/sort_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Inquisitor.JsonApi.SortTest do 2 | use Inquisitor.JsonApi.TestCase 3 | 4 | @context %{} 5 | 6 | defmodule Base do 7 | require Ecto.Query 8 | use Inquisitor 9 | use Inquisitor.JsonApi.Sort 10 | end 11 | 12 | test "sort age ascending" do 13 | q = Base.build_query(User, @context, %{"sort" => "age"}) 14 | assert to_sql(q) == {~s{SELECT u0."id", u0."name", u0."age" FROM "users" AS u0 ORDER BY u0."age"}, []} 15 | end 16 | 17 | test "sort age decending" do 18 | q = Base.build_query(User, @context, %{"sort" => "-age"}) 19 | assert to_sql(q) == {~s{SELECT u0."id", u0."name", u0."age" FROM "users" AS u0 ORDER BY u0."age" DESC}, []} 20 | end 21 | 22 | test "sort on multiple fields" do 23 | q = Base.build_query(User, @context, %{"sort" => "-age,name"}) 24 | assert to_sql(q) == {~s{SELECT u0."id", u0."name", u0."age" FROM "users" AS u0 ORDER BY u0."age" DESC, u0."name"}, []} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/jsonapi/include_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Inquisitor.JsonApi.IncludeTest do 2 | use Inquisitor.JsonApi.TestCase 3 | 4 | @context %{} 5 | 6 | defmodule NoOp do 7 | require Ecto.Query 8 | use Inquisitor 9 | use Inquisitor.JsonApi.Include 10 | end 11 | 12 | test "defaults to no-op by default when no include handlers are defined" do 13 | q = NoOp.build_query(User, @context, %{"include" => "posts"}) 14 | assert q == User 15 | end 16 | 17 | defmodule Composed do 18 | require Ecto.Query 19 | use Inquisitor 20 | use Inquisitor.JsonApi.Include 21 | 22 | def build_include_query(query, include, _context) do 23 | Ecto.Query.preload(query, ^String.to_atom(include)) 24 | end 25 | end 26 | 27 | test "builds query with composed matchers" do 28 | q = Composed.build_query(User, @context, %{"include" => "posts"}) 29 | assert q.preloads == [:posts] 30 | end 31 | 32 | test "preload parser" do 33 | parsed = Inquisitor.JsonApi.Include.preload_parser("foo.bar.baz.qux") 34 | assert parsed == [foo: [bar: [baz: :qux]]] 35 | 36 | parsed = Inquisitor.JsonApi.Include.preload_parser("foo") 37 | assert parsed == :foo 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/jsonapi/filter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Inquisitor.JsonApi.FilterTest do 2 | use Inquisitor.JsonApi.TestCase 3 | 4 | @context %{} 5 | 6 | defmodule NoOp do 7 | require Ecto.Query 8 | use Inquisitor 9 | use Inquisitor.JsonApi.Filter 10 | end 11 | 12 | test "defaults to no-op by default when no filter handlers are defined" do 13 | q = NoOp.build_query(User, @context, %{"filter" => %{"name" => "Brian"}}) 14 | assert to_sql(q) == {~s{SELECT u0."id", u0."name", u0."age" FROM "users" AS u0}, []} 15 | end 16 | 17 | defmodule Composed do 18 | require Ecto.Query 19 | use Inquisitor 20 | use Inquisitor.JsonApi.Filter 21 | 22 | def build_filter_query(query, "name", name, _context) do 23 | Ecto.Query.where(query, [r], r.name == ^name) 24 | end 25 | 26 | def build_filter_query(query, "age", age, _context) do 27 | Ecto.Query.where(query, [r], r.age == ^age) 28 | end 29 | end 30 | 31 | test "builds query with composed matchers" do 32 | q = Composed.build_query(User, @context, %{"filter" => %{"name" => "Brian", "age" => "99"}}) 33 | assert to_sql(q) == {~s{SELECT u0."id", u0."name", u0."age" FROM "users" AS u0 WHERE (u0."age" = $1) AND (u0."name" = $2)}, [99, "Brian"]} 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /pages/Default Query Scopes.md: -------------------------------------------------------------------------------- 1 | # Default Query Scopes 2 | 3 | You may have need for default query scopes. One pattern for achieving 4 | this in Phoenix is to use a controller `plug`. 5 | 6 | defmodule MyApp.PostController do 7 | use MyApp.Web, :controller 8 | use Inquisitor.JsonApi 9 | 10 | plug :default_params, :index when action == :index 11 | 12 | def index(conn, params) do 13 | posts = 14 | Post 15 | |> build_query(conn, params) 16 | |> Repo.all() 17 | 18 | render(conn, data: posts) 19 | end 20 | 21 | defp default_params(conn, :index) do 22 | params = Map.put_new(conn.params, "sort", "-published_at") 23 | %{conn | params: params} 24 | end 25 | end 26 | 27 | In the above example the `default_params/2` plug will allow us to modify 28 | the inbound params map. We want to set a default `sort` value but not 29 | overwrite if one is being requested. So we can use `Map.put_new/3` which 30 | will add the key/value pair to the map only if it isn't already there. 31 | 32 | We allow Inqusitor to handle the params, we just care about setting it 33 | to our desired state before its handled. 34 | 35 | You can use this pattern to set default params for all actions. 36 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :inquisitor_jsonapi, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:inquisitor_jsonapi, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | 32 | if Mix.env == :test do 33 | import_config "test.exs" 34 | end 35 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule InquisitorJsonapi.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [app: :inquisitor_jsonapi, 6 | version: "0.1.0", 7 | elixir: "~> 1.3", 8 | build_embedded: Mix.env == :prod, 9 | start_permanent: Mix.env == :prod, 10 | elixirc_paths: elixirc_paths(Mix.env), 11 | description: description(), 12 | package: package(), 13 | deps: deps(), 14 | docs: [ 15 | extras: ["pages/Default\ Query\ Scopes.md"] 16 | ]] 17 | end 18 | 19 | # Configuration for the OTP application 20 | # 21 | # Type "mix help compile.app" for more information 22 | def application do 23 | [applications: applications(Mix.env())] 24 | end 25 | 26 | defp description(), do: "JSON API Handlers for Inquisitor" 27 | 28 | defp package() do 29 | [maintainers: ["Brian Cardarella"], 30 | licenses: ["MIT"], 31 | links: %{"GitHub" => "https://github.com/DockYard/inquisitor_jsonapi"} 32 | ] 33 | end 34 | 35 | defp elixirc_paths(:test), do: elixirc_paths(:dev) |> Enum.concat(["test/support"]) 36 | defp elixirc_paths(_), do: ["lib"] 37 | 38 | defp applications(:test), do: applications(:dev) |> Enum.concat([:postgrex]) 39 | defp applications(_), do: [:logger, :inquisitor] 40 | 41 | # Dependencies can be Hex packages: 42 | # 43 | # {:mydep, "~> 0.3.0"} 44 | # 45 | # Or git/path repositories: 46 | # 47 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 48 | # 49 | # Type "mix help deps" for more examples and options 50 | defp deps do 51 | [{:inquisitor, "~> 0.5.0"}, 52 | {:ecto, "> 2.0.0"}, 53 | {:plug, "~> 1.3.0", only: :test}, 54 | {:earmark, "~> 0.1", only: :dev}, 55 | {:ex_doc, "~> 0.11", only: :dev}, 56 | {:postgrex, "> 0.0.0", only: :test}] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], []}, 2 | "db_connection": {:hex, :db_connection, "1.1.2", "2865c2a4bae0714e2213a0ce60a1b12d76a6efba0c51fbda59c9ab8d1accc7a8", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, optional: true]}]}, 3 | "decimal": {:hex, :decimal, "1.3.1", "157b3cedb2bfcb5359372a7766dd7a41091ad34578296e951f58a946fcab49c6", [:mix], []}, 4 | "earmark": {:hex, :earmark, "0.2.1", "ba6d26ceb16106d069b289df66751734802777a3cbb6787026dd800ffeb850f3", [:mix], []}, 5 | "ecto": {:hex, :ecto, "2.1.4", "d1ba932813ec0e0d9db481ef2c17777f1cefb11fc90fa7c142ff354972dfba7e", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, optional: true]}]}, 6 | "ex_doc": {:hex, :ex_doc, "0.12.0", "b774aabfede4af31c0301aece12371cbd25995a21bb3d71d66f5c2fe074c603f", [:mix], [{:earmark, "~> 0.2", [hex: :earmark, optional: false]}]}, 7 | "inquisitor": {:hex, :inquisitor, "0.5.0", "5798a77ce1d0e7816687b15eebd401975a7dca76c2b89aa56b4487119761cb3e", [:mix], []}, 8 | "mime": {:hex, :mime, "1.1.0", "01c1d6f4083d8aa5c7b8c246ade95139620ef8effb009edde934e0ec3b28090a", [:mix], []}, 9 | "plug": {:hex, :plug, "1.3.4", "b4ef3a383f991bfa594552ded44934f2a9853407899d47ecc0481777fb1906f6", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, optional: true]}, {:mime, "~> 1.0", [hex: :mime, optional: false]}]}, 10 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], []}, 11 | "postgrex": {:hex, :postgrex, "0.13.2", "2b88168fc6a5456a27bfb54ccf0ba4025d274841a7a3af5e5deb1b755d95154e", [:mix], [{:connection, "~> 1.0", [hex: :connection, optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, optional: false]}]}} 12 | -------------------------------------------------------------------------------- /lib/inquisitor/jsonapi/filter.ex: -------------------------------------------------------------------------------- 1 | defmodule Inquisitor.JsonApi.Filter do 2 | @moduledoc """ 3 | Inquisitor query handlers for JSON API filters 4 | 5 | [JSON API Spec](http://jsonapi.org/format/#fetching-filtering) 6 | 7 | #### Usage 8 | 9 | `use` the module *after* the `Inquisitor` module: 10 | 11 | defmodule MyApp do 12 | use Inquisitor 13 | use Inquisitor.JsonApi.Filter 14 | 15 | ... 16 | end 17 | 18 | This module allows you to decide how you want to handle filter key/value params. 19 | For example you may query your API with the following URL: 20 | 21 | https://example.com/posts?filter[foo]=bar&filter[baz]=qux 22 | 23 | You can use `build_filter_query/4` to define matchers: 24 | 25 | def build_filter_query(query, "foo", value, _conn) do 26 | Ecto.Query.where(query, [r], r.foo == ^value) 27 | end 28 | 29 | def build_filter_query(query, "baz", value, _conn) do 30 | Ecto.Query.where(query, [r], r.baz > ^value) 31 | end 32 | 33 | #### General key/value matcher 34 | 35 | You may want a handler that simply queries on key/value pairs. Use the following: 36 | 37 | def build_filter_query(query, key, value, _conn) do 38 | Ecto.Query.where(query, [r], Ecto.Query.API.field(r, ^String.to_existing_atom(key)) == ^value) 39 | end 40 | 41 | #### Security 42 | 43 | This module is secure by default. Meaning that you must opt-in to handle the filter params. 44 | Otherwise they are ignored by the query builder. 45 | 46 | If you would like to limit the values to act upon use a `guard`: 47 | 48 | @filter_whitelist ~w(name title) 49 | def build_filter_query(query, key, value, _conn) where key in @filter_whitelist do 50 | Ecto.Query.where(query, [r], Ecto.Query.API.field(r, ^String.to_existing_atom(key)) == ^value) 51 | end 52 | """ 53 | require Inquisitor 54 | 55 | defmacro __using__(_opts) do 56 | quote do 57 | def build_query(query, "filter", filters, context) do 58 | Enum.reduce(filters, query, fn({key, value}, query) -> 59 | build_filter_query(query, key, value, context) 60 | end) 61 | end 62 | 63 | @before_compile Inquisitor.JsonApi.Filter 64 | end 65 | end 66 | 67 | defmacro __before_compile__(_env) do 68 | quote generated: true do 69 | def build_filter_query(query, _key, _value, _context), do: query 70 | defoverridable [build_filter_query: 4] 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /test/jsonapi/page_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Inquisitor.JsonApi.PageTest do 2 | use Inquisitor.JsonApi.TestCase 3 | 4 | @context %{} 5 | 6 | defmodule Base do 7 | require Ecto.Query 8 | use Inquisitor 9 | use Inquisitor.JsonApi.Page 10 | end 11 | 12 | test "supports `page[number]` and `page[size]`" do 13 | q = Base.build_query(User, @context, %{"page" => %{"number" => "1", "size" => "10"}}) 14 | assert to_sql(q) == {~s{SELECT u0."id", u0."name", u0."age" FROM "users" AS u0 LIMIT $1 OFFSET $2}, [10, 0]} 15 | end 16 | 17 | test "supports `page[number]` and `page[size]` with multiple pages" do 18 | q = Base.build_query(User, @context, %{"page" => %{"number" => "2", "size" => "10"}}) 19 | assert to_sql(q) == {~s{SELECT u0."id", u0."name", u0."age" FROM "users" AS u0 LIMIT $1 OFFSET $2}, [10, 10]} 20 | end 21 | 22 | test "supports `page[number]` and `page[size]` non-strings" do 23 | q = Base.build_query(User, @context, %{"page" => %{"number" => 1, "size" => 10}}) 24 | assert to_sql(q) == {~s{SELECT u0."id", u0."name", u0."age" FROM "users" AS u0 LIMIT $1 OFFSET $2}, [10, 0]} 25 | end 26 | 27 | test "supports `page[offset]` and `page[limit]`" do 28 | q = Base.build_query(User, @context, %{"page" => %{"offset" => "1", "limit" => "10"}}) 29 | assert to_sql(q) == {~s{SELECT u0."id", u0."name", u0."age" FROM "users" AS u0 LIMIT $1 OFFSET $2}, [10, 1]} 30 | 31 | q = Base.build_query(User, @context, %{"page" => %{"offset" => 1, "limit" => 10}}) 32 | assert to_sql(q) == {~s{SELECT u0."id", u0."name", u0."age" FROM "users" AS u0 LIMIT $1 OFFSET $2}, [10, 1]} 33 | end 34 | 35 | test "calculates page data with `page[number]` and `page[size]`" do 36 | Repo.insert!(%User{name: "Foo", age: 1}) 37 | Repo.insert!(%User{name: "Bar", age: 2}) 38 | Repo.insert!(%User{name: "Baz", age: 3}) 39 | Repo.insert!(%User{name: "Qux", age: 4}) 40 | 41 | params = %{"page" => %{"number" => "1", "size" => "2"}} 42 | 43 | page_data = 44 | User 45 | |> Base.build_query(@context, params) 46 | |> Inquisitor.JsonApi.Page.page_data(Repo, params) 47 | 48 | expected = %{ 49 | number: 1, 50 | size: 2, 51 | total: 2, 52 | count: 4 53 | } 54 | 55 | assert expected == page_data 56 | end 57 | 58 | test "calculates page data with `page[offset]` and `page[limit]`" do 59 | Repo.insert!(%User{name: "Foo", age: 1}) 60 | Repo.insert!(%User{name: "Bar", age: 2}) 61 | Repo.insert!(%User{name: "Baz", age: 3}) 62 | 63 | params = %{"page" => %{"offset" => "0", "limit" => "2"}} 64 | 65 | page_data = 66 | User 67 | |> Base.build_query(@context, params) 68 | |> Inquisitor.JsonApi.Page.page_data(Repo, params) 69 | 70 | expected = %{ 71 | number: 0, 72 | size: 2, 73 | total: 2, 74 | count: 3 75 | } 76 | 77 | assert expected == page_data 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/inquisitor/jsonapi/include.ex: -------------------------------------------------------------------------------- 1 | defmodule Inquisitor.JsonApi.Include do 2 | @moduledoc """ 3 | Inquisitor query handlers for JSON API includes 4 | 5 | [JSON API Spec](http://jsonapi.org/format/#fetching-includes) 6 | 7 | #### Usage 8 | 9 | `use` the module *after* the `Inquisitor` module: 10 | 11 | defmodule MyApp do 12 | use Inquisitor 13 | use Inquisitor.JsonApi.Include 14 | 15 | ... 16 | end 17 | 18 | this module allow you to decide how to you want to handle include params. 19 | For example you may query your API with the following URL: 20 | 21 | https://example.com/posts?include=tags,author 22 | 23 | You can use `build_include_query/3` to define matchers: 24 | 25 | def build_include_query(query, include, _context) do 26 | Ecto.Query.preload(query, ^String.to_existing_atom(include)) 27 | end 28 | 29 | #### Relationship paths 30 | 31 | The value for an include could be dot-seperated to indicate a nesting: 32 | 33 | author.profile 34 | 35 | If you want to parse and `preload` this relationship properly: 36 | 37 | def build_incude_query(query, include, _context) do 38 | preload = Inquisitor.JsonApi.Include.preload_parser(include) 39 | Ecto.Query.preload(query, preload) 40 | end 41 | 42 | For the given include of `author.profile` the result of `Inquisitor.JsonApi.Include.preload_parser/1` 43 | would be `[author: :profile]`. The parser can handle infinite depths: 44 | 45 | preload_parser("foo.bar.baz.qux") 46 | 47 | > [foo: [bar: [baz: :qux]]] 48 | 49 | #### Security 50 | 51 | This module is secure by default. Meaning that you must opt-in to handle the include params. 52 | Otherwise they are ignored by the query builder. 53 | 54 | If you would like to limit the values to act upon use a `guard`: 55 | 56 | @include_whitelist ~w(tags author) 57 | def build_include_query(query, include, _context) when include in @include_whitelist do 58 | Ecto.Query.preload(query, ^String.to_existing_atom(include)) 59 | end 60 | """ 61 | require Inquisitor 62 | 63 | defmacro __using__(_opts) do 64 | quote do 65 | def build_query(query, "include", includes, context) do 66 | includes 67 | |> String.split(",") 68 | |> Enum.reduce(query, fn(include, query) -> 69 | build_include_query(query, include, context) 70 | end) 71 | end 72 | 73 | @before_compile Inquisitor.JsonApi.Include 74 | end 75 | end 76 | 77 | defmacro __before_compile__(_env) do 78 | quote generated: true do 79 | def build_include_query(query, include, context), do: query 80 | defoverridable [build_include_query: 3] 81 | end 82 | end 83 | 84 | @doc """ 85 | Parse path segments into nested keyword list 86 | 87 | Example: 88 | "foo.bar.baz.qux" 89 | |> preload_parser() 90 | 91 | > [foo: [bar: [baz: :qux]]] 92 | """ 93 | def preload_parser(path) do 94 | path 95 | |> String.split(".") 96 | |> build_segments() 97 | end 98 | 99 | defp build_segments([segment | []]), 100 | do: String.to_existing_atom(segment) 101 | defp build_segments([segment | segments]) do 102 | [{String.to_existing_atom(segment), build_segments(segments)}] 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/inquisitor/jsonapi/page.ex: -------------------------------------------------------------------------------- 1 | defmodule Inquisitor.JsonApi.Page do 2 | @moduledoc """ 3 | Inquisitor query handlers for JSON API page 4 | 5 | [JSON API Spec](http://jsonapi.org/format/#fetching-pagination) 6 | 7 | #### Usage 8 | 9 | `use` the module *after* the `Inquisitor` module: 10 | 11 | defmodule MyApp do 12 | use Inquisitor 13 | use Inquisitor.JsonApi.Page 14 | 15 | ... 16 | end 17 | """ 18 | 19 | require Inquisitor 20 | import Ecto.Query 21 | 22 | defmacro __using__(_opts) do 23 | quote do 24 | import Inquisitor.JsonApi.Page, only: [page_data: 3] 25 | 26 | def build_query(query, "page", pages, context) do 27 | build_page_query(query, pages, context) 28 | end 29 | 30 | @before_compile Inquisitor.JsonApi.Page 31 | end 32 | end 33 | 34 | defmacro __before_compile__(_env) do 35 | quote generated: true do 36 | def build_page_query(query, %{"number" => number, "size" => size}, _context) do 37 | number = Inquisitor.JsonApi.Page.typecast_as_integer(number) 38 | size = Inquisitor.JsonApi.Page.typecast_as_integer(size) 39 | 40 | offset = (number - 1) * size 41 | 42 | Inquisitor.JsonApi.Page.offset_and_limit(query, offset: offset, limit: size) 43 | end 44 | def build_page_query(query, %{"offset" => offset, "limit" => limit}, _context) do 45 | Inquisitor.JsonApi.Page.offset_and_limit(query, offset: offset, limit: limit) 46 | end 47 | def build_page_query(query, _pages, _context), do: query 48 | 49 | defoverridable [build_page_query: 3] 50 | end 51 | end 52 | 53 | @doc """ 54 | Calculate pagination data from query 55 | 56 | Will result in a map: 57 | 58 | %{ number: current_page_number, size: page_size, total: numer_of_all_pages, count: number_of_entries } 59 | 60 | Example: 61 | data = page_data(query, repo, params) 62 | """ 63 | def page_data(query, repo, %{"page" => %{"number" => number, "size" => size}} = _params) do 64 | build_page_data(query, repo, typecast(number), typecast(size)) 65 | end 66 | def page_data(query, repo, %{"page" => %{"offset" => offset, "limit" => limit}} = _params) do 67 | build_page_data(query, repo, typecast(offset), typecast(limit)) 68 | end 69 | 70 | defp build_page_data(query, repo, number, size) do 71 | count = calculate_count(query, repo) 72 | total = calculate_total(count, size) 73 | 74 | %{number: number, size: size, total: total, count: count} 75 | end 76 | 77 | defp calculate_count(query, repo) do 78 | query 79 | |> exclude(:offset) 80 | |> exclude(:limit) 81 | |> exclude(:preload) 82 | |> exclude(:select) 83 | |> exclude(:order_by) 84 | |> subquery() 85 | |> select(count("*")) 86 | |> repo.one() 87 | |> Kernel.||(0) 88 | end 89 | 90 | defp calculate_total(count, size) do 91 | (count / size) |> Float.ceil() |> round() 92 | end 93 | 94 | defp typecast(integer) when is_integer(integer), do: integer 95 | defp typecast(integer) when is_binary(integer) do 96 | String.to_integer(integer) 97 | end 98 | 99 | @doc false 100 | def offset_and_limit(query, [offset: offset, limit: limit]) do 101 | query 102 | |> Ecto.Query.offset(^offset) 103 | |> Ecto.Query.limit(^limit) 104 | end 105 | 106 | @doc false 107 | def typecast_as_integer(integer) when is_binary(integer) do 108 | String.to_integer(integer) 109 | end 110 | def typecast_as_integer(integer), do: integer 111 | end 112 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inquisitor JSONAPI [![Build Status](https://secure.travis-ci.org/DockYard/inquisitor_jsonapi.svg?branch=master)](http://travis-ci.org/DockYard/inquisitor_jsonapi) 2 | 3 | Easily build composable queries for Ecto for JSON API endpoints using 4 | [Inquisitor](https://github.com/dockyard/inquisitor) 5 | 6 | **[Inquisitor JSONAPI is built and maintained by DockYard, contact us for expert Elixir and Phoenix consulting](https://dockyard.com/phoenix-consulting)**. 7 | 8 | This plugin for [Inquisitor](https://github.com/dockyard/inquisitor) 9 | aims to implement all of the relevant [Fetching 10 | Data](http://jsonapi.org/format/#fetching) section for the [JSON API spec](http://jsonapi.org/) 11 | 12 | [Make sure you reference Inquisitor's Usage section 13 | first](https://github.com/DockYard/inquisitor#usage) 14 | 15 | #### Progress 16 | 17 | * - [x] [Include](http://jsonapi.org/format/#fetching-includes) 18 | * - [ ] [Field](http://jsonapi.org/format/#fetching-sparse-fieldsets) 19 | * - [x] [Sort](http://jsonapi.org/format/#fetching-sorting) 20 | * [Page](http://jsonapi.org/format/#fetching-pagination) 21 | * - [x] `order,limit` 22 | * - [x] `number,size` 23 | * - [ ] `cursor` 24 | * - [x] [Filter](http://jsonapi.org/format/#fetching-filtering) 25 | 26 | ## Include 27 | 28 | JSON API Include (Ecto preload) Plugin 29 | 30 | ### Usage 31 | 32 | Use `Inquisitor.JsonApi.Include` *after* `Inquisitor` 33 | 34 | ```elixir 35 | defmodule MyApp.PostController do 36 | use MyAp.Web, :controller 37 | use Inquisitor 38 | use Inquisitor.JsonApi.Include 39 | 40 | ... 41 | ``` 42 | 43 | [This plugin follows the spec for sorting with JSON 44 | API](http://jsonapi.org/format/#fetching-includes). All requests should 45 | conform to that URL schema for this plugin to work. 46 | 47 | `[GET] http://example.com/posts?include=tags,author` 48 | 49 | Refer to the Docs for this module on how to enable preloading properly. 50 | 51 | ## Sort 52 | 53 | JSON API Sorting Plugin 54 | 55 | ### Usage 56 | 57 | Use `Inquisitor.JsonApi.Sort` *after* `Inquisitor` 58 | 59 | ```elixir 60 | defmodule MyApp.PostController do 61 | use MyAp.Web, :controller 62 | use Inquisitor 63 | use Inquisitor.JsonApi.Sort 64 | 65 | ... 66 | ``` 67 | 68 | [This plugin follows the spec for sorting with JSON 69 | API](http://jsonapi.org/format/#fetching-sorting). All requests should 70 | conform to that URL schema for this plugin to work. 71 | 72 | `[GET] http://example.com/posts?sort=-create,title` 73 | 74 | The plugin with correct apply `ASC` and `DESC` sort order to the built 75 | query. 76 | 77 | ## Page 78 | 79 | JSON API Pagination Plugin 80 | 81 | ### Usage 82 | 83 | Use `Inquisitor.JsonApi.Page` *after* `Inquisitor` 84 | 85 | ```elixir 86 | defmodule MyApp.PostController do 87 | use MyAp.Web, :controller 88 | use Inquisitor 89 | use Inquisitor.JsonApi.Page 90 | 91 | ... 92 | ``` 93 | 94 | [This plugin follows the spec for pagination with JSON 95 | API](http://jsonapi.org/format/#fetching-pagination). All requests should 96 | conform to that URL schema for this plugin to work. 97 | 98 | `[GET] http://example.com/posts?page[limit]=10&page[offset]=2` 99 | `[GET] http://example.com/posts?page[size]=10&page[number]=2` 100 | 101 | Cursor pagination is not yet implemented. 102 | 103 | You may need to calculate certain page data to generate pagination 104 | links. You can use `page_data/3` that this module `import`s for you. 105 | 106 | ```elixir 107 | query = build_query(User, conn, params) 108 | data = page_data(query, repo, params) 109 | 110 | links = build_links(data) 111 | meta = build_meta(data) 112 | users = Repo.all(query) 113 | ``` 114 | 115 | ## Filter 116 | 117 | JSON API Filtering Plugin 118 | 119 | ### Usage 120 | 121 | Use `Inquisitor.JsonApi.Filter` *after* `Inquisitor` 122 | 123 | ```elixir 124 | defmodule MyApp.PostController do 125 | use MyAp.Web, :controller 126 | use Inquisitor 127 | use Inquisitor.JsonApi.Filter 128 | 129 | ... 130 | ``` 131 | 132 | [This plugin follows the spec for pagination with JSON 133 | API](http://jsonapi.org/format/#fetching-filtering). All requests should 134 | conform to that URL schema for this plugin to work. 135 | 136 | `[GET] http://example.com/posts?filter[name]=Brian&filter[age]=99` 137 | 138 | By default `Filter` is no-op. You must define a custom 139 | `build_filter_query/4` handler: 140 | 141 | ```elixir 142 | def build_filter_query(query, "name", name, _conn) do 143 | Ecto.Query.where(query, [r], r.name == ^name) 144 | end 145 | ``` 146 | 147 | ## Authors 148 | 149 | * [Brian Cardarella](http://twitter.com/bcardarella) 150 | 151 | [We are very thankful for the many contributors](https://github.com/dockyard/inquisitor_jsonapi/graphs/contributors) 152 | 153 | ## Versioning 154 | 155 | This library follows [Semantic Versioning](http://semver.org) 156 | 157 | ## Want to help? 158 | 159 | Please do! We are always looking to improve this library. Please see our 160 | [Contribution Guidelines](https://github.com/dockyard/inquisitor_jsonapi/blob/master/CONTRIBUTING.md) 161 | on how to properly submit issues and pull requests. 162 | 163 | ## Legal 164 | 165 | [DockYard](http://dockyard.com/), Inc. © 2017 166 | 167 | [@dockyard](http://twitter.com/dockyard) 168 | 169 | [Licensed under the MIT license](http://www.opensource.org/licenses/mit-license.php) 170 | --------------------------------------------------------------------------------