├── priv └── plts │ └── .gitkeep ├── test ├── test_helper.exs ├── utils │ ├── list_test.exs │ ├── string_test.exs │ ├── include_tree_test.exs │ └── data_to_params_test.exs ├── jsonapi │ ├── plugs │ │ ├── response_content_type_test.exs │ │ ├── id_required_test.exs │ │ ├── underscore_parameters_test.exs │ │ ├── content_type_negotiation_test.exs │ │ ├── deserializer_test.exs │ │ ├── query_parser_test.exs │ │ └── format_required_test.exs │ └── view_test.exs └── jsonapi_test.exs ├── .github ├── CODEOWNERS ├── release-please-manifest.json ├── dependabot.yml ├── workflows │ ├── release.yaml │ ├── production.yaml │ ├── common-config.yaml │ ├── pr.yaml │ ├── stale.yaml │ └── ci.yaml └── release-please-config.json ├── .tool-versions ├── .gitignore ├── .formatter.exs ├── lib ├── jsonapi │ ├── plugs │ │ ├── ensure_spec.ex │ │ ├── response_content_type.ex │ │ ├── id_required.ex │ │ ├── deserializer.ex │ │ ├── content_type_negotiation.ex │ │ ├── format_required.ex │ │ ├── underscore_parameters.ex │ │ └── query_parser.ex │ ├── paginator.ex │ ├── config.ex │ ├── deprecation.ex │ ├── exceptions.ex │ ├── utils │ │ ├── include_tree.ex │ │ ├── list.ex │ │ ├── data_to_params.ex │ │ └── string.ex │ ├── error_view.ex │ ├── serializer.ex │ └── view.ex └── jsonapi.ex ├── LICENSE ├── mix.exs ├── mix.lock ├── .credo.exs ├── README.md └── CHANGELOG.md /priv/plts/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @beam-community/team 2 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.16 2 | erlang 26.0 3 | -------------------------------------------------------------------------------- /.github/release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "1.8.1" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /deps 3 | erl_crash.dump 4 | *.ez 5 | /doc 6 | 7 | # Dialyzer 8 | /priv/plts/*.plt 9 | /priv/plts/*.plt.hash 10 | 11 | .DS_Store 12 | .elixir_ls 13 | -------------------------------------------------------------------------------- /test/utils/list_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Utils.ListTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | 6 | import JSONAPI.Utils.List 7 | 8 | doctest JSONAPI.Utils.List 9 | end 10 | -------------------------------------------------------------------------------- /test/utils/string_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Utils.StringTest do 2 | @moduledoc false 3 | 4 | use ExUnit.Case, async: true 5 | 6 | import JSONAPI.Utils.String 7 | 8 | doctest JSONAPI.Utils.String 9 | end 10 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | [ 4 | import_deps: [], 5 | inputs: ["*.{heex,ex,exs}", "{config,lib,priv,test}/**/*.{heex,ex,exs}"], 6 | line_length: 120, 7 | plugins: [] 8 | ] 9 | -------------------------------------------------------------------------------- /lib/jsonapi/plugs/ensure_spec.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.EnsureSpec do 2 | @moduledoc """ 3 | A helper Plug to enforce the JSON API specification 4 | """ 5 | 6 | use Plug.Builder 7 | 8 | alias JSONAPI.{ContentTypeNegotiation, FormatRequired, IdRequired, ResponseContentType} 9 | 10 | plug(ContentTypeNegotiation) 11 | plug(FormatRequired) 12 | plug(IdRequired) 13 | plug(ResponseContentType) 14 | end 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | commit-message: 8 | prefix: "chore(deps)" 9 | 10 | - package-ecosystem: mix 11 | directory: "/" 12 | schedule: 13 | interval: weekly 14 | commit-message: 15 | prefix: "chore(deps)" 16 | groups: 17 | prod: 18 | dependency-type: production 19 | dev: 20 | dependency-type: development 21 | -------------------------------------------------------------------------------- /lib/jsonapi/paginator.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Paginator do 2 | @moduledoc """ 3 | Pagination strategy behaviour 4 | """ 5 | 6 | alias Plug.Conn 7 | 8 | @type options :: Keyword.t() 9 | 10 | @type page :: map() 11 | 12 | @type params :: %{String.t() => String.t()} 13 | 14 | @type links :: %{ 15 | first: String.t() | nil, 16 | last: String.t() | nil, 17 | next: String.t() | nil, 18 | prev: String.t() | nil 19 | } 20 | 21 | @callback paginate(data :: term, view :: atom, conn :: Conn.t(), page, options) :: links 22 | end 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | name: Release 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | Please: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - id: release 16 | name: Release 17 | uses: google-github-actions/release-please-action@v4 18 | with: 19 | command: manifest 20 | config-file: .github/release-please-config.json 21 | manifest-file: .github/release-please-manifest.json 22 | release-type: elixir 23 | token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 24 | -------------------------------------------------------------------------------- /lib/jsonapi/config.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Config do 2 | @moduledoc """ 3 | Configuration struct containing JSON API information for a request 4 | """ 5 | 6 | defstruct data: nil, 7 | fields: %{}, 8 | filter: [], 9 | include: [], 10 | opts: nil, 11 | sort: nil, 12 | view: nil, 13 | page: %{} 14 | 15 | @type t :: %__MODULE__{ 16 | data: nil | map, 17 | fields: map, 18 | filter: keyword, 19 | include: [atom | {atom, any}], 20 | opts: nil | keyword, 21 | sort: nil | keyword, 22 | view: any, 23 | page: nil | map 24 | } 25 | end 26 | -------------------------------------------------------------------------------- /lib/jsonapi/deprecation.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Deprecation do 2 | @moduledoc """ 3 | Generate warnings in places where we want to deprecate functions or struct parameters 4 | """ 5 | 6 | @doc """ 7 | Generates a deprecation warning for using `fields[relationship_key]` instead of `fields[type]` when 8 | parsing query parameters. 9 | """ 10 | def warn(:query_parser_fields) do 11 | IO.warn( 12 | "`JSONAPI.QueryParser` will no longer accept `fields` query params that refer to the relationship key of a `JSONAPI.View`. Please use the `type` of the resource to perform filtering. 13 | See: https://github.com/jeregrine/jsonapi/pull/203.", 14 | Macro.Env.stacktrace(__ENV__) 15 | ) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/jsonapi/plugs/response_content_type.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.ResponseContentType do 2 | @moduledoc """ 3 | Simply add this plug to your endpoint or your router :api pipeline and it will 4 | ensure you return the correct response type. 5 | 6 | If you need to override the response type simple set conn.assigns[:override_jsonapi] 7 | and this will be skipped. 8 | """ 9 | @behaviour Plug 10 | import Plug.Conn 11 | 12 | def init(_opts) do 13 | end 14 | 15 | def call(conn, _opts) do 16 | register_before_send(conn, fn conn -> 17 | if conn.assigns[:override_jsonapi] do 18 | conn 19 | else 20 | put_resp_content_type(conn, JSONAPI.mime_type()) 21 | end 22 | end) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /.github/workflows/production.yaml: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | name: Production 4 | 5 | on: 6 | release: 7 | types: 8 | - released 9 | - prereleased 10 | workflow_dispatch: 11 | 12 | concurrency: 13 | group: Production 14 | 15 | jobs: 16 | Hex: 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v6 22 | 23 | - name: Setup Elixir 24 | uses: stordco/actions-elixir/setup@v1 25 | with: 26 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 27 | 28 | - name: Compile 29 | run: mix compile --docs 30 | 31 | - name: Publish 32 | run: mix hex.publish --yes 33 | env: 34 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 35 | 36 | -------------------------------------------------------------------------------- /test/jsonapi/plugs/response_content_type_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.ResponseContentTypeTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | alias JSONAPI.ResponseContentType 6 | 7 | test "sets response content type" do 8 | conn = 9 | :get 10 | |> conn("/example", "") 11 | |> ResponseContentType.call([]) 12 | |> send_resp(200, "done") 13 | 14 | assert get_resp_header(conn, "content-type") == ["#{JSONAPI.mime_type()}; charset=utf-8"] 15 | end 16 | 17 | test "can be overridden when in play" do 18 | conn = 19 | :get 20 | |> conn("/example", "") 21 | |> Plug.Conn.assign(:override_jsonapi, true) 22 | |> ResponseContentType.call([]) 23 | |> send_resp(200, "done") 24 | 25 | refute get_resp_header(conn, "content-type") == ["#{JSONAPI.mime_type()}; charset=utf-8"] 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/jsonapi/plugs/id_required.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.IdRequired do 2 | @moduledoc """ 3 | Ensure that the URL id matches the id in the request body and is a string 4 | """ 5 | 6 | import JSONAPI.ErrorView 7 | 8 | def init(opts), do: opts 9 | 10 | def call(%{method: method} = conn, _opts) when method in ["DELETE", "GET", "HEAD", "POST"], 11 | do: conn 12 | 13 | def call(%{params: %{"data" => %{"id" => id}, "id" => id}} = conn, _) when is_binary(id), 14 | do: conn 15 | 16 | def call(%{params: %{"data" => %{"id" => id}}} = conn, _) when not is_binary(id), 17 | do: send_error(conn, malformed_id()) 18 | 19 | def call(%{params: %{"data" => %{"id" => id}, "id" => _id}} = conn, _) when is_binary(id), 20 | do: send_error(conn, mismatched_id()) 21 | 22 | def call(%{params: %{"id" => _id}} = conn, _), do: send_error(conn, missing_id()) 23 | def call(conn, _), do: conn 24 | end 25 | -------------------------------------------------------------------------------- /lib/jsonapi/exceptions.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Exceptions do 2 | defmodule InvalidQuery do 3 | @moduledoc """ 4 | Defines a generic exception for when an invalid query is received and is unable to be parsed nor handled. 5 | 6 | All JSONAPI exceptions on index routes return a 400. 7 | """ 8 | defexception plug_status: 400, 9 | message: "invalid query", 10 | resource: nil, 11 | param: nil, 12 | param_type: nil 13 | 14 | @spec exception(keyword()) :: Exception.t() 15 | def exception(opts) do 16 | resource = Keyword.fetch!(opts, :resource) 17 | param = Keyword.fetch!(opts, :param) 18 | type = Keyword.fetch!(opts, :param_type) 19 | 20 | %InvalidQuery{ 21 | message: "invalid #{type}, #{param} for type #{resource}", 22 | resource: resource, 23 | param: param, 24 | param_type: type 25 | } 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/utils/include_tree_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.IncludeTreeTest do 2 | use ExUnit.Case 3 | import JSONAPI.Utils.IncludeTree 4 | 5 | test "put_as_tree\3 builds the path" do 6 | items = [:test, :the, :path] 7 | assert put_as_tree([], items, :boo) == [test: [the: [path: :boo]]] 8 | end 9 | 10 | test "deep_merge/2 handles string/keyword conflict by choosing second value" do 11 | # one direction 12 | assert [other: "thing", hi: [hello: "there"]] = deep_merge([other: "thing", hi: "there"], hi: [hello: "there"]) 13 | # the other direction 14 | assert [hi: "there", other: "thing"] = deep_merge([hi: [hello: "there"]], other: "thing", hi: "there") 15 | end 16 | 17 | test "deep_merge/2 handles string/string conflict by choosing second value" do 18 | # one direction 19 | assert [hi: "there"] = deep_merge([hi: "hello"], hi: "there") 20 | # the other direction 21 | assert [hi: "hello"] = deep_merge([hi: "there"], hi: "hello") 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /.github/release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$comment": "This file is synced with beam-community/common-config. Any changes will be overwritten.", 3 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", 4 | "changelog-sections": [ 5 | { 6 | "type": "feat", 7 | "section": "Features", 8 | "hidden": false 9 | }, 10 | { 11 | "type": "fix", 12 | "section": "Bug Fixes", 13 | "hidden": false 14 | }, 15 | { 16 | "type": "refactor", 17 | "section": "Miscellaneous", 18 | "hidden": false 19 | } 20 | ], 21 | "draft": false, 22 | "draft-pull-request": false, 23 | "packages": { 24 | ".": { 25 | "extra-files": [ 26 | "README.md" 27 | ], 28 | "release-type": "elixir" 29 | } 30 | }, 31 | "plugins": [ 32 | { 33 | "type": "sentence-case" 34 | } 35 | ], 36 | "prerelease": false, 37 | "pull-request-header": "An automated release has been created for you.", 38 | "separate-pull-requests": true 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) 2024 BEAM Community 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/jsonapi.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI do 2 | @moduledoc """ 3 | A module for working with the JSON API specification in Elixir 4 | """ 5 | 6 | @mime_type "application/vnd.api+json" 7 | 8 | @doc """ 9 | Returns the configured JSON encoding library for JSONAPI. 10 | To customize the JSON library, including the following 11 | in your `config/config.exs`: 12 | config :jsonapi, :json_library, Jason 13 | """ 14 | @spec json_library :: module() 15 | def json_library do 16 | module = Application.get_env(:jsonapi, :json_library, Jason) 17 | 18 | if Code.ensure_loaded?(module) do 19 | module 20 | else 21 | IO.write(:stderr, """ 22 | failed to load #{inspect(module)} for JSONAPI JSON encoding. 23 | (module #{inspect(module)} is not available) 24 | Ensure #{inspect(module)} is loaded from your deps in mix.exs, or 25 | configure an existing encoder in your mix config using: 26 | config :jsonapi, :json_library, MyJSONLibrary 27 | """) 28 | end 29 | end 30 | 31 | @doc """ 32 | This returns the MIME type for JSONAPIs 33 | """ 34 | @spec mime_type :: binary() 35 | def mime_type, do: @mime_type 36 | end 37 | -------------------------------------------------------------------------------- /.github/workflows/common-config.yaml: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | name: Common Config 4 | 5 | on: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - .github/workflows/common-config.yaml 11 | repository_dispatch: 12 | types: 13 | - common-config 14 | schedule: 15 | - cron: "8 12 8 * *" 16 | workflow_dispatch: {} 17 | 18 | concurrency: 19 | group: Common Config 20 | 21 | jobs: 22 | Sync: 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v6 28 | with: 29 | token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 30 | persist-credentials: true 31 | 32 | - name: Setup Node 33 | uses: actions/setup-node@v6 34 | with: 35 | node-version: 20 36 | 37 | - name: Setup Elixir 38 | uses: stordco/actions-elixir/setup@v1 39 | with: 40 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 41 | elixir-version: "1.15" 42 | otp-version: "26.0" 43 | 44 | - name: Sync 45 | uses: stordco/actions-sync@v1 46 | with: 47 | commit-message: "chore: sync files with beam-community/common-config" 48 | pr-enabled: true 49 | pr-labels: common-config 50 | pr-title: "chore: sync files with beam-community/common-config" 51 | pr-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 52 | sync-auth: doomspork:${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 53 | sync-branch: latest 54 | sync-repository: github.com/beam-community/common-config.git 55 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | name: PR 4 | 5 | on: 6 | merge_group: 7 | pull_request: 8 | types: 9 | - edited 10 | - opened 11 | - reopened 12 | - synchronize 13 | 14 | jobs: 15 | Title: 16 | if: ${{ github.event_name == 'pull_request' }} 17 | name: Check Title 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: Check 22 | uses: stordco/actions-pr-title@v1.0.0 23 | with: 24 | regex: '^(refactor!|feat!|fix!|refactor|fix|feat|chore)(\(\w+\))?:\s(\[#\d{1,5}\])?.*$' 25 | hint: | 26 | Your PR title does not match the Conventional Commits convention. Please rename your PR to match one of the following formats: 27 | 28 | fix: [#123] some title of the PR 29 | fix(scope): [#123] some title of the PR 30 | feat: [#1234] some title of the PR 31 | chore: update some action 32 | 33 | Note: Adding ! (i.e. `feat!:`) represents a breaking change and will result in a SemVer major release. 34 | 35 | Please use one of the following types: 36 | 37 | - **feat:** A new feature, resulting in a MINOR version bump. 38 | - **fix:** A bug fix, resulting in a PATCH version bump. 39 | - **refactor:** A code change that neither fixes a bug nor adds a feature. 40 | - **chore:** Changes unrelated to the release code, resulting in no version bump. 41 | - **revert:** Reverts a previous commit. 42 | 43 | See https://www.conventionalcommits.org/en/v1.0.0/ for more information. 44 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/include_tree.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Utils.IncludeTree do 2 | @moduledoc """ 3 | Internal utility for building trees of resource relationships 4 | """ 5 | 6 | @spec deep_merge(Keyword.t(), Keyword.t()) :: Keyword.t() 7 | def deep_merge(acc, []), do: acc 8 | 9 | def deep_merge(acc, [{key, val} | tail]) do 10 | acc 11 | |> Keyword.update( 12 | key, 13 | val, 14 | fn 15 | [_first | _rest] = old_val when is_list(val) -> deep_merge(old_val, val) 16 | _ -> val 17 | end 18 | ) 19 | |> deep_merge(tail) 20 | end 21 | 22 | @spec put_as_tree(term(), term(), term()) :: term() 23 | def put_as_tree(acc, items, val) do 24 | [head | tail] = Enum.reverse(items) 25 | build_tree(Keyword.put(acc, head, val), tail) 26 | end 27 | 28 | def build_tree(acc, []), do: acc 29 | 30 | def build_tree(acc, [head | tail]) do 31 | build_tree(Keyword.put([], head, acc), tail) 32 | end 33 | 34 | @spec member_of_tree?(term(), term()) :: boolean() 35 | def member_of_tree?([], _thing), do: true 36 | def member_of_tree?(_thing, []), do: false 37 | 38 | def member_of_tree?([path | tail], include) when is_list(include) do 39 | if Keyword.has_key?(include, path) do 40 | member_of_tree?(tail, get_base_relationships(include[path])) 41 | else 42 | false 43 | end 44 | end 45 | 46 | @spec get_base_relationships(tuple()) :: term() 47 | def get_base_relationships({view, :include}), do: get_base_relationships(view) 48 | 49 | def get_base_relationships(view) do 50 | Enum.map(view.relationships(), fn 51 | {view, :include} -> view 52 | view -> view 53 | end) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/list.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Utils.List do 2 | @moduledoc false 3 | 4 | @doc """ 5 | Transforms a Map into a List of Tuples that can be converted into a query string via URI.encode_query/1 6 | 7 | ## Examples 8 | 9 | iex> to_list_of_query_string_components(%{"number" => 5}) 10 | [{"number", 5}] 11 | 12 | iex> to_list_of_query_string_components(%{color: "red"}) 13 | [{:color, "red"}] 14 | 15 | iex> to_list_of_query_string_components(%{"alphabet" => ["a", "b", "c"]}) 16 | [{"alphabet[]", "a"}, {"alphabet[]", "b"}, {"alphabet[]", "c"}] 17 | 18 | iex> to_list_of_query_string_components(%{"filters" => %{"age" => 18, "name" => "John"}}) 19 | [{"filters[age]", 18}, {"filters[name]", "John"}] 20 | 21 | iex> to_list_of_query_string_components(%{"filter" => %{"age" => 18, "car" => %{"make" => "honda", "model" => "civic"}}}) 22 | [{"filter[age]", 18}, {"filter[car][make]", "honda"}, {"filter[car][model]", "civic"}] 23 | """ 24 | @spec to_list_of_query_string_components(map()) :: list(tuple()) 25 | def to_list_of_query_string_components(map) when is_map(map) do 26 | Enum.flat_map(map, &do_to_list_of_query_string_components/1) 27 | end 28 | 29 | defp do_to_list_of_query_string_components({key, value}) when is_list(value) do 30 | to_list_of_two_elem_tuple(key, value) 31 | end 32 | 33 | defp do_to_list_of_query_string_components({key, value}) when is_map(value) do 34 | Enum.flat_map(value, fn {k, v} -> 35 | do_to_list_of_query_string_components({"#{key}[#{k}]", v}) 36 | end) 37 | end 38 | 39 | defp do_to_list_of_query_string_components({key, value}), 40 | do: to_list_of_two_elem_tuple(key, value) 41 | 42 | defp to_list_of_two_elem_tuple(key, value) when is_list(value) do 43 | Enum.map(value, &{"#{key}[]", &1}) 44 | end 45 | 46 | defp to_list_of_two_elem_tuple(key, value) do 47 | [{key, value}] 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/jsonapi/plugs/deserializer.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Deserializer do 2 | @moduledoc """ 3 | This plug flattens incoming params for ease of use when casting to changesets. 4 | As a result, you are able to pattern match specific attributes in your controller 5 | actions. 6 | 7 | Note that this Plug will only deserialize your payload when the request's content 8 | type is for a JSON:API request (i.e. "application/vnd.api+json"). All other 9 | content types will be ignored. 10 | 11 | ## Example 12 | 13 | For example these params: 14 | %{ 15 | "data" => %{ 16 | "id" => "1", 17 | "type" => "user", 18 | "attributes" => %{ 19 | "foo-bar" => true 20 | }, 21 | "relationships" => %{ 22 | "baz" => %{"data" => %{"id" => "2", "type" => "baz"}} 23 | } 24 | } 25 | } 26 | 27 | are transformed to: 28 | 29 | %{ 30 | "id" => "1", 31 | "type" => "user" 32 | "foo-bar" => true, 33 | "baz-id" => "2" 34 | } 35 | 36 | ## Usage 37 | 38 | Just include in your plug stack _after_ a json parser: 39 | plug Plug.Parsers, parsers: [:json], json_decoder: Jason 40 | plug JSONAPI.Deserializer 41 | 42 | or a part of your Controller plug pipeline 43 | plug JSONAPI.Deserializer 44 | 45 | In addition, if you want to underscore your parameters 46 | plug JSONAPI.Deserializer 47 | plug JSONAPI.UnderscoreParameters 48 | """ 49 | 50 | import Plug.Conn 51 | alias JSONAPI.Utils.DataToParams 52 | 53 | @spec init(Keyword.t()) :: Keyword.t() 54 | def init(opts), do: opts 55 | 56 | @spec call(Plug.Conn.t(), Keyword.t()) :: Plug.Conn.t() 57 | def call(conn, _opts) do 58 | content_type = get_req_header(conn, "content-type") 59 | 60 | if JSONAPI.mime_type() in content_type do 61 | Map.put(conn, :params, DataToParams.process(conn.params)) 62 | else 63 | conn 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: "Close stale issues and PRs" 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "30 1 * * *" 7 | 8 | permissions: 9 | contents: write 10 | issues: write 11 | pull-requests: write 12 | 13 | jobs: 14 | stale: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/stale@v10 18 | with: 19 | days-before-issue-stale: 30 20 | days-before-issue-close: 15 21 | days-before-pr-stale: 60 22 | days-before-pr-close: 60 23 | 24 | stale-issue-label: "stale:discard" 25 | exempt-issue-labels: "stale:keep" 26 | stale-issue-message: > 27 | This issue has been automatically marked as "stale:discard". We are sorry that we haven't been able to 28 | prioritize it yet. 29 | 30 | If this issue still relevant, please leave any comment if you have any new additional information that 31 | helps to solve this issue. We encourage you to create a pull request, if you can. We are happy to help you 32 | with that. 33 | 34 | close-issue-message: > 35 | Closing this issue after a prolonged period of inactivity. If this issue is still relevant, feel free to 36 | re-open the issue. Thank you! 37 | 38 | stale-pr-label: "stale:discard" 39 | exempt-pr-labels: "stale:keep" 40 | stale-pr-message: > 41 | This pull request has been automatically marked as "stale:discard". **If this pull request is still 42 | relevant, please leave any comment** (for example, "bump"), and we'll keep it open. We are sorry that we 43 | haven't been able to prioritize reviewing it yet. 44 | Your contribution is very much appreciated!. 45 | close-pr-message: > 46 | Closing this pull request after a prolonged period of inactivity. If this issue is still relevant, please 47 | ask for this pull request to be reopened. Thank you! 48 | -------------------------------------------------------------------------------- /test/jsonapi/plugs/id_required_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.IdRequiredTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | alias JSONAPI.IdRequired 6 | 7 | test "halts and returns an error if id attribute is missing" do 8 | conn = 9 | :patch 10 | |> conn("/example/1", Jason.encode!(%{data: %{}})) 11 | |> call_plug 12 | 13 | assert conn.halted 14 | assert 400 == conn.status 15 | 16 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 17 | 18 | assert %{"source" => %{"pointer" => "/data/id"}, "title" => "Missing id in data parameter"} = 19 | error 20 | end 21 | 22 | test "halts and returns an error if id attribute is not a string" do 23 | conn = 24 | :patch 25 | |> conn("/example/1", Jason.encode!(%{data: %{id: 1}})) 26 | |> call_plug 27 | 28 | assert conn.halted 29 | assert 422 == conn.status 30 | 31 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 32 | 33 | assert %{"source" => %{"pointer" => "/data/id"}, "title" => "Malformed id in data parameter"} = 34 | error 35 | end 36 | 37 | test "halts and returns an error if id attribute and url id are mismatched" do 38 | conn = 39 | :patch 40 | |> conn("/example/1", Jason.encode!(%{data: %{id: "2"}})) 41 | |> call_plug 42 | 43 | assert conn.halted 44 | assert 409 == conn.status 45 | 46 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 47 | 48 | assert %{"source" => %{"pointer" => "/data/id"}, "title" => "Mismatched id parameter"} = error 49 | end 50 | 51 | test "passes request through" do 52 | conn = 53 | :patch 54 | |> conn("/example/1", Jason.encode!(%{data: %{id: "1"}})) 55 | |> call_plug 56 | 57 | refute conn.halted 58 | end 59 | 60 | defp call_plug(%{path_info: [_, id]} = conn) do 61 | parser_opts = Plug.Parsers.init(parsers: [:json], pass: ["text/*"], json_decoder: Jason) 62 | 63 | conn 64 | |> Plug.Conn.put_req_header("content-type", "application/json") 65 | |> Map.put(:path_params, %{"id" => id}) 66 | |> Plug.Parsers.call(parser_opts) 67 | |> IdRequired.call([]) 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :jsonapi, 7 | version: "1.10.0", 8 | package: package(), 9 | compilers: compilers(Mix.env()), 10 | description: description(), 11 | elixir: "~> 1.10", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | build_embedded: Mix.env() == :prod, 14 | start_permanent: Mix.env() == :prod, 15 | source_url: "https://github.com/beam-community/jsonapi", 16 | deps: deps(), 17 | dialyzer: dialyzer(), 18 | docs: [ 19 | extras: [ 20 | "README.md" 21 | ], 22 | main: "readme" 23 | ] 24 | ] 25 | end 26 | 27 | # Use Phoenix compiler depending on environment. 28 | defp compilers(_), do: Mix.compilers() 29 | 30 | # Specifies which paths to compile per environment. 31 | defp elixirc_paths(:test), do: ["lib", "test/support"] 32 | defp elixirc_paths(_), do: ["lib"] 33 | 34 | def application do 35 | [ 36 | extra_applications: [:logger] 37 | ] 38 | end 39 | 40 | defp dialyzer do 41 | [ 42 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, 43 | plt_add_deps: :app_tree 44 | ] 45 | end 46 | 47 | defp deps do 48 | [ 49 | {:plug, "~> 1.10"}, 50 | {:jason, "~> 1.0", optional: true}, 51 | {:ex_doc, "~> 0.20", only: :dev}, 52 | {:earmark, ">= 0.0.0", only: :dev}, 53 | {:credo, "~> 1.4", only: [:dev, :test], runtime: false}, 54 | {:phoenix, "~> 1.3", only: :test}, 55 | {:dialyxir, "~> 1.4.2", only: [:dev, :test], runtime: false} 56 | ] 57 | end 58 | 59 | defp package do 60 | [ 61 | maintainers: [ 62 | "Jason Stiebs", 63 | "Mitchell Henke", 64 | "Jake Robers", 65 | "Sean Callan", 66 | "James Herdman", 67 | "Mathew Polzin" 68 | ], 69 | licenses: ["MIT"], 70 | links: %{ 71 | github: "https://github.com/beam-community/jsonapi", 72 | docs: "http://hexdocs.pm/jsonapi/" 73 | } 74 | ] 75 | end 76 | 77 | defp description do 78 | """ 79 | Fully functional JSONAPI V1 Serializer as well as a QueryParser for Plug based projects and applications. 80 | """ 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/jsonapi/plugs/content_type_negotiation.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.ContentTypeNegotiation do 2 | @moduledoc """ 3 | Provides content type negotiation by validating the `content-type` 4 | and `accept` headers. 5 | 6 | The proper jsonapi.org content type is 7 | `application/vnd.api+json`. As per [the spec](http://jsonapi.org/format/#content-negotiation-servers) 8 | 9 | This plug does three things: 10 | 11 | 1. Returns 415 unless the content-type header is correct. 12 | 2. Returns 406 unless the accept header is correct. 13 | 3. Registers a before send hook to set the content-type if not already set. 14 | """ 15 | 16 | import JSONAPI.ErrorView 17 | 18 | import Plug.Conn 19 | 20 | def init(opts), do: opts 21 | 22 | def call(%{method: method} = conn, _opts) when method in ["DELETE", "GET", "HEAD"], do: conn 23 | 24 | def call(conn, _opts) do 25 | conn 26 | |> content_type 27 | |> accepts 28 | |> respond 29 | end 30 | 31 | defp accepts({conn, content_type}) do 32 | accepts = 33 | conn 34 | |> get_req_header("accept") 35 | |> List.first() 36 | 37 | {conn, content_type, accepts} 38 | end 39 | 40 | defp content_type(conn) do 41 | content_type = 42 | conn 43 | |> get_req_header("content-type") 44 | |> List.first() 45 | 46 | {conn, content_type} 47 | end 48 | 49 | defp respond({conn, content_type, accepts}) do 50 | cond do 51 | validate_header(content_type) and validate_header(accepts) == true -> 52 | add_header_to_resp(conn) 53 | 54 | validate_header(content_type) == false -> 55 | send_error(conn, incorrect_content_type()) 56 | 57 | validate_header(accepts) == false -> 58 | send_error(conn, 406) 59 | end 60 | end 61 | 62 | defp validate_header(string) when is_binary(string) do 63 | string 64 | |> String.split(",") 65 | |> Enum.map(&String.trim/1) 66 | |> Enum.member?(JSONAPI.mime_type()) 67 | end 68 | 69 | defp validate_header(nil), do: true 70 | 71 | defp add_header_to_resp(conn) do 72 | register_before_send(conn, fn conn -> 73 | update_resp_header( 74 | conn, 75 | "content-type", 76 | JSONAPI.mime_type(), 77 | & &1 78 | ) 79 | end) 80 | 81 | conn 82 | end 83 | end 84 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | name: CI 4 | 5 | on: 6 | merge_group: 7 | pull_request: 8 | types: 9 | - opened 10 | - reopened 11 | - synchronize 12 | push: 13 | branches: 14 | - main 15 | workflow_call: 16 | secrets: 17 | GH_PERSONAL_ACCESS_TOKEN: 18 | required: true 19 | workflow_dispatch: 20 | 21 | concurrency: 22 | group: CI ${{ github.head_ref || github.run_id }} 23 | cancel-in-progress: true 24 | 25 | jobs: 26 | Credo: 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v6 32 | 33 | - name: Setup Elixir 34 | uses: stordco/actions-elixir/setup@v1 35 | with: 36 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 37 | 38 | - name: Credo 39 | run: mix credo --strict 40 | 41 | Dependencies: 42 | runs-on: ubuntu-latest 43 | 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v6 47 | 48 | - name: Setup Elixir 49 | uses: stordco/actions-elixir/setup@v1 50 | with: 51 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 52 | 53 | - name: Unused 54 | run: mix deps.unlock --check-unused 55 | 56 | Dialyzer: 57 | runs-on: ubuntu-latest 58 | 59 | steps: 60 | - name: Checkout 61 | uses: actions/checkout@v6 62 | 63 | - name: Setup Elixir 64 | uses: stordco/actions-elixir/setup@v1 65 | with: 66 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 67 | 68 | - name: Dialyzer 69 | run: mix dialyzer --format github 70 | 71 | Format: 72 | runs-on: ubuntu-latest 73 | 74 | steps: 75 | - name: Checkout 76 | uses: actions/checkout@v6 77 | 78 | - name: Setup Elixir 79 | uses: stordco/actions-elixir/setup@v1 80 | with: 81 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 82 | 83 | - name: Format 84 | run: mix format --check-formatted 85 | 86 | Test: 87 | name: Test (Elixir ${{ matrix.versions.elixir }} OTP ${{ matrix.versions.otp }}) 88 | 89 | runs-on: ubuntu-latest 90 | 91 | env: 92 | MIX_ENV: test 93 | 94 | steps: 95 | - name: Checkout 96 | uses: actions/checkout@v6 97 | 98 | - name: Setup Elixir 99 | uses: stordco/actions-elixir/setup@v1 100 | with: 101 | elixir-version: ${{ matrix.versions.elixir }} 102 | github-token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} 103 | otp-version: ${{ matrix.versions.otp }} 104 | 105 | - name: Compile 106 | run: mix compile --warnings-as-errors 107 | 108 | - name: Test 109 | run: mix test 110 | 111 | strategy: 112 | fail-fast: false 113 | matrix: 114 | versions: 115 | - elixir: 1.15 116 | otp: 26 117 | - elixir: 1.16 118 | otp: 26 119 | - elixir: 1.17 120 | otp: 27 121 | 122 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/data_to_params.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Utils.DataToParams do 2 | @moduledoc ~S""" 3 | Converts a Map representation of the JSON:API resource object format into a flat Map convenient for 4 | changeset casting. 5 | """ 6 | alias JSONAPI.Utils.String, as: JString 7 | 8 | @spec process(map) :: map 9 | def process(%{"data" => nil}), do: nil 10 | 11 | def process(%{"data" => _} = incoming) do 12 | incoming 13 | |> flatten_incoming() 14 | |> process_included() 15 | |> process_relationships() 16 | |> process_attributes() 17 | end 18 | 19 | def process(incoming), do: incoming 20 | 21 | defp flatten_incoming(%{"data" => data}) when is_list(data) do 22 | data 23 | end 24 | 25 | defp flatten_incoming(%{"data" => data} = incoming) do 26 | incoming 27 | |> Map.merge(data) 28 | |> Map.drop(["data"]) 29 | end 30 | 31 | ## Attributes 32 | 33 | defp process_attributes(%{"attributes" => nil} = data) do 34 | Map.drop(data, ["attributes"]) 35 | end 36 | 37 | defp process_attributes(%{"attributes" => attributes} = data) do 38 | data 39 | |> Map.merge(attributes) 40 | |> Map.drop(["attributes"]) 41 | end 42 | 43 | defp process_attributes(data), do: data 44 | 45 | ## Relationships 46 | 47 | defp process_relationships(%{"relationships" => nil} = data) do 48 | Map.drop(data, ["relationships"]) 49 | end 50 | 51 | defp process_relationships(%{"relationships" => relationships} = data) do 52 | relationships 53 | |> Enum.reduce(%{}, &transform_relationship/2) 54 | |> Map.merge(data) 55 | |> Map.drop(["relationships"]) 56 | end 57 | 58 | defp process_relationships(data), do: data 59 | 60 | defp transform_relationship({key, %{"data" => nil}}, acc) do 61 | Map.put(acc, transform_fields("#{key}-id"), nil) 62 | end 63 | 64 | defp transform_relationship({key, %{"data" => %{"id" => id}}}, acc) do 65 | Map.put(acc, transform_fields("#{key}-id"), id) 66 | end 67 | 68 | defp transform_relationship({_key, %{"data" => list}}, acc) when is_list(list) do 69 | Enum.reduce(list, acc, fn %{"id" => id, "type" => type}, inner_acc -> 70 | {_val, new_map} = 71 | Map.get_and_update( 72 | inner_acc, 73 | transform_fields("#{type}-id"), 74 | &update_list_relationship(&1, id) 75 | ) 76 | 77 | new_map 78 | end) 79 | end 80 | 81 | defp update_list_relationship(existing, id) do 82 | case existing do 83 | val when is_list(val) -> {val, Enum.reverse([id | val])} 84 | val when is_binary(val) -> {val, [val, id]} 85 | _ -> {nil, id} 86 | end 87 | end 88 | 89 | ## Included 90 | 91 | defp process_included(%{"included" => nil} = incoming) do 92 | Map.drop(incoming, ["included"]) 93 | end 94 | 95 | defp process_included(%{"included" => included} = incoming) do 96 | included 97 | |> Enum.reduce(incoming, fn %{"type" => type} = params, acc -> 98 | flattened = process(%{"data" => params}) 99 | 100 | case Map.has_key?(acc, type) do 101 | false -> Map.put(acc, type, [flattened]) 102 | true -> Map.update(acc, type, flattened, &[flattened | &1]) 103 | end 104 | end) 105 | |> Map.drop(["included"]) 106 | end 107 | 108 | defp process_included(incoming), do: incoming 109 | 110 | defp transform_fields(fields) do 111 | case JString.field_transformation() do 112 | :camelize -> JString.expand_fields(fields, &JString.camelize/1) 113 | :camelize_shallow -> JString.expand_fields(fields, &JString.camelize/1) 114 | :dasherize -> JString.expand_fields(fields, &JString.dasherize/1) 115 | :dasherize_shallow -> JString.expand_fields(fields, &JString.dasherize/1) 116 | _ -> fields 117 | end 118 | end 119 | end 120 | -------------------------------------------------------------------------------- /lib/jsonapi/plugs/format_required.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.FormatRequired do 2 | @moduledoc """ 3 | Enforces the JSONAPI format of {"data" => {"attributes" => ...}} for request bodies 4 | """ 5 | 6 | import JSONAPI.ErrorView 7 | 8 | # Cf. https://jsonapi.org/format/#crud-updating-to-many-relationships 9 | @update_has_many_relationships_methods ~w[DELETE PATCH POST] 10 | 11 | def init(opts), do: opts 12 | 13 | def call(%{method: method} = conn, _opts) when method in ~w[DELETE GET HEAD], do: conn 14 | 15 | def call( 16 | %{method: method, params: %{"data" => %{"type" => _, "relationships" => relationships}}} = 17 | conn, 18 | _ 19 | ) 20 | when method in ~w[POST PATCH] and not is_map(relationships) do 21 | send_error(conn, relationships_missing_object()) 22 | end 23 | 24 | def call( 25 | %{ 26 | method: method, 27 | params: %{"data" => %{"type" => _, "relationships" => relationships}} 28 | } = conn, 29 | _ 30 | ) 31 | when method in ~w[POST PATCH] and is_map(relationships) do 32 | errors = 33 | Enum.reduce(relationships, [], fn 34 | {_relationship_name, %{"data" => %{"type" => _type, "id" => _}}}, acc -> 35 | acc 36 | 37 | {relationship_name, %{"data" => %{"type" => _type}}}, acc -> 38 | error = missing_relationship_data_id_param_error_attrs(relationship_name) 39 | [error | acc] 40 | 41 | {relationship_name, %{"data" => %{"id" => _type}}}, acc -> 42 | error = missing_relationship_data_type_param_error_attrs(relationship_name) 43 | [error | acc] 44 | 45 | {relationship_name, %{"data" => %{}}}, acc -> 46 | id_error = missing_relationship_data_id_param_error_attrs(relationship_name) 47 | type_error = missing_relationship_data_type_param_error_attrs(relationship_name) 48 | [id_error | [type_error | acc]] 49 | 50 | {_relationship_name, %{"data" => _}}, acc -> 51 | # Allow things other than resource identifier objects per https://jsonapi.org/format/#document-resource-object-linkage 52 | # - null for empty to-one relationships. 53 | # - an empty array ([]) for empty to-many relationships. 54 | # - an array of resource identifier objects for non-empty to-many relationships. 55 | acc 56 | 57 | {relationship_name, _}, acc -> 58 | error = missing_relationship_data_param_error_attrs(relationship_name) 59 | [error | acc] 60 | end) 61 | 62 | if Enum.empty?(errors) do 63 | conn 64 | else 65 | send_error(conn, serialize_errors(errors)) 66 | end 67 | end 68 | 69 | def call(%{method: "POST", params: %{"data" => %{"type" => _}}} = conn, _), do: conn 70 | 71 | def call(%{method: method, params: %{"data" => [%{"type" => _} | _]}} = conn, _) 72 | when method in @update_has_many_relationships_methods do 73 | if String.contains?(conn.request_path, "relationships") do 74 | conn 75 | else 76 | send_error(conn, to_many_relationships_payload_for_standard_endpoint()) 77 | end 78 | end 79 | 80 | def call(%{params: %{"data" => %{"type" => _, "id" => _}}} = conn, _), do: conn 81 | 82 | def call(%{method: "PATCH", params: %{"data" => %{"attributes" => _, "type" => _}}} = conn, _) do 83 | send_error(conn, missing_data_id_param()) 84 | end 85 | 86 | def call(%{method: "PATCH", params: %{"data" => %{"attributes" => _, "id" => _}}} = conn, _) do 87 | send_error(conn, missing_data_type_param()) 88 | end 89 | 90 | def call(%{params: %{"data" => %{"attributes" => _}}} = conn, _), 91 | do: send_error(conn, missing_data_type_param()) 92 | 93 | def call(%{params: %{"data" => _}} = conn, _), 94 | do: send_error(conn, missing_data_attributes_param()) 95 | 96 | def call(conn, _), do: send_error(conn, missing_data_param()) 97 | end 98 | -------------------------------------------------------------------------------- /lib/jsonapi/plugs/underscore_parameters.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.UnderscoreParameters do 2 | @moduledoc """ 3 | Takes dasherized JSON:API params and converts them to underscored params. Add 4 | this to your API's pipeline to aid in dealing with incoming parameters such as query 5 | params or data. 6 | 7 | By default the newly underscored params will only replace the existing `params` field 8 | of the `Plug.Conn` struct, but leave the `query_params` and `body_params` untouched. 9 | If you are using the `JSONAPI.QueryParser` and need to also have the `query_params` on 10 | the `Plug.Conn` updated, set the `replace_query_params` option to `true`. 11 | 12 | Note that this Plug will only underscore parameters when the request's content 13 | type is for a JSON:API request (i.e. "application/vnd.api+json"). All other 14 | content types will be ignored. 15 | 16 | ## Options 17 | 18 | * `:replace_query_params` - When `true`, it will replace the `query_params` field in 19 | the `Plug.Conn` struct. This is useful when you have downstream code which is 20 | expecting underscored fields in `query_params`, and not just in `params`. Defaults 21 | to `false`. 22 | 23 | ## Example 24 | 25 | %{ 26 | "data" => %{ 27 | "attributes" => %{ 28 | "foo-bar" => true 29 | } 30 | } 31 | } 32 | 33 | are transformed to: 34 | 35 | %{ 36 | "data" => %{ 37 | "attributes" => %{ 38 | "foo_bar" => true 39 | } 40 | } 41 | } 42 | 43 | Moreover, with a GET request like: 44 | 45 | GET /example?filters[dog-breed]=Corgi 46 | 47 | **Without** this Plug your index action would look like: 48 | 49 | def index(conn, %{"filters" => %{"dog-breed" => "Corgi"}}) 50 | 51 | And **with** this Plug: 52 | 53 | def index(conn, %{"filters" => %{"dog_breed" => "Corgi"}}) 54 | 55 | Your API's pipeline might look something like this: 56 | 57 | # e.g. a Phoenix app 58 | 59 | pipeline :api do 60 | plug JSONAPI.EnforceSpec 61 | plug JSONAPI.UnderscoreParameters 62 | end 63 | """ 64 | 65 | import Plug.Conn 66 | 67 | alias JSONAPI.Utils.String, as: JString 68 | 69 | @doc false 70 | def init(opts) do 71 | opt = Keyword.fetch(opts, :replace_query_params) 72 | 73 | if match?({:ok, b} when not is_boolean(b), opt) do 74 | raise ArgumentError, 75 | message: """ 76 | The :replace_query_params option must be a boolean. Example: 77 | 78 | pipeline :api do 79 | plug JSONAPI.UnderscoreParameters, replace_query_params: true 80 | end 81 | """ 82 | else 83 | opts 84 | end 85 | end 86 | 87 | @doc false 88 | def call(%Plug.Conn{params: params} = conn, opts) do 89 | content_type = get_req_header(conn, "content-type") 90 | 91 | if JSONAPI.mime_type() in content_type do 92 | # In version 2.0, when this block is no longer conditional and applies every time, ensure 93 | # that we apply the same treatment to the query_params and "regular" params. 94 | conn = 95 | if opts[:replace_query_params] do 96 | query_params = fetch_query_params(conn).query_params 97 | new_query_params = replace_query_params(query_params) 98 | Map.put(conn, :query_params, new_query_params) 99 | else 100 | conn 101 | end 102 | 103 | new_params = JString.expand_fields(params, &JString.underscore/1) 104 | Map.put(conn, :params, new_params) 105 | else 106 | conn 107 | end 108 | end 109 | 110 | defp replace_query_params(query_params) do 111 | # Underscore the keys of all of the query parameters 112 | underscored_query_params = JString.expand_fields(query_params, &JString.underscore/1) 113 | 114 | # If the fields[...] query parameter is present, only underscore its values, but not its keys 115 | case Map.fetch(query_params, "fields") do 116 | {:ok, fields} when is_map(fields) -> 117 | fields = Map.new(fields, fn {k, v} -> {k, JString.underscore(v)} end) 118 | Map.put(underscored_query_params, "fields", fields) 119 | 120 | _else -> 121 | underscored_query_params 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /test/jsonapi/plugs/underscore_parameters_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.UnderscoreParametersTest do 2 | use ExUnit.Case, async: true 3 | use Plug.Test 4 | 5 | alias JSONAPI.UnderscoreParameters 6 | 7 | describe "call/2" do 8 | test "underscores dasherized data parameters" do 9 | params = %{ 10 | "data" => %{ 11 | "attributes" => %{ 12 | "first-name" => "John", 13 | "last-name" => "Cleese", 14 | "stats" => %{ 15 | "age" => 45, 16 | "dog-name" => "Pedro" 17 | } 18 | } 19 | }, 20 | "filter" => %{ 21 | "dog-breed" => "Corgi" 22 | } 23 | } 24 | 25 | conn = 26 | :get 27 | |> conn("/hello", params) 28 | |> put_req_header("content-type", JSONAPI.mime_type()) 29 | 30 | assert %Plug.Conn{ 31 | params: %{ 32 | "data" => %{ 33 | "attributes" => %{ 34 | "first_name" => "John", 35 | "last_name" => "Cleese", 36 | "stats" => %{ 37 | "age" => "45", 38 | "dog_name" => "Pedro" 39 | } 40 | } 41 | }, 42 | "filter" => %{ 43 | "dog_breed" => "Corgi" 44 | } 45 | } 46 | } = UnderscoreParameters.call(conn, []) 47 | 48 | params = %{ 49 | "data" => %{ 50 | "attributes" => %{ 51 | "math-problem" => "1-1=2" 52 | } 53 | } 54 | } 55 | 56 | conn = 57 | :get 58 | |> conn("/example", params) 59 | |> put_req_header("content-type", JSONAPI.mime_type()) 60 | 61 | assert %Plug.Conn{ 62 | params: %{ 63 | "data" => %{ 64 | "attributes" => %{ 65 | "math_problem" => "1-1=2" 66 | } 67 | } 68 | } 69 | } = UnderscoreParameters.call(conn, []) 70 | end 71 | 72 | test ":replace_query_params option replaces filter[...] keys in the Conn's query_params" do 73 | conn = 74 | :get 75 | |> conn("?filter[favorite-food]=pizza") 76 | |> put_req_header("content-type", JSONAPI.mime_type()) 77 | 78 | # Before: filter name is dasherized 79 | assert %{"favorite-food" => _} = fetch_query_params(conn).query_params["filter"] 80 | 81 | # After: filter name is underscored 82 | updated_conn = UnderscoreParameters.call(conn, replace_query_params: true) 83 | assert %{"favorite_food" => _} = fetch_query_params(updated_conn).query_params["filter"] 84 | 85 | # After (without option): filter name remains dasherized 86 | updated_conn = UnderscoreParameters.call(conn, []) 87 | assert %{"favorite-food" => _} = fetch_query_params(updated_conn).query_params["filter"] 88 | end 89 | 90 | test ":replace_query_params option replaces fields[...] values in the Conn's query_params" do 91 | conn = 92 | :get 93 | |> conn("?fields[favorite-food]=is-fried") 94 | |> put_req_header("content-type", JSONAPI.mime_type()) 95 | 96 | # Before: key and value are dasherized 97 | assert %{"favorite-food" => "is-fried"} = fetch_query_params(conn).query_params["fields"] 98 | 99 | # After: key is unchanged and value is underscored 100 | updated_conn = UnderscoreParameters.call(conn, replace_query_params: true) 101 | 102 | assert %{"favorite-food" => "is_fried"} = 103 | fetch_query_params(updated_conn).query_params["fields"] 104 | 105 | # After (without option): key and value remain dasherized 106 | updated_conn = UnderscoreParameters.call(conn, []) 107 | 108 | assert %{"favorite-food" => "is-fried"} = 109 | fetch_query_params(updated_conn).query_params["fields"] 110 | end 111 | 112 | test "does not transform when the content type is not for json:api" do 113 | params = %{ 114 | "data" => %{ 115 | "attributes" => %{ 116 | "dog-breed" => "Corgi" 117 | } 118 | } 119 | } 120 | 121 | conn = conn(:get, "/hello", params) 122 | 123 | assert %Plug.Conn{ 124 | params: %{ 125 | "data" => %{ 126 | "attributes" => %{ 127 | "dog-breed" => "Corgi" 128 | } 129 | } 130 | } 131 | } = UnderscoreParameters.call(conn, []) 132 | end 133 | end 134 | 135 | describe "init/1" do 136 | test "the replace_query_params option must be a boolean" do 137 | # These are okay 138 | assert UnderscoreParameters.init([]) 139 | assert UnderscoreParameters.init(replace_query_params: true) 140 | assert UnderscoreParameters.init(replace_query_params: false) 141 | 142 | # These are not allowed 143 | assert_raise ArgumentError, fn -> 144 | UnderscoreParameters.init(replace_query_params: 1) 145 | end 146 | 147 | assert_raise ArgumentError, fn -> 148 | UnderscoreParameters.init(foo: "bar", replace_query_params: 1) 149 | end 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /test/jsonapi/plugs/content_type_negotiation_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.ContentTypeNegotiationTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | import JSONAPI, only: [mime_type: 0] 6 | 7 | alias JSONAPI.ContentTypeNegotiation 8 | 9 | test "passes request through" do 10 | conn = 11 | :post 12 | |> conn("/example", "") 13 | |> Plug.Conn.put_req_header("content-type", mime_type()) 14 | |> Plug.Conn.put_req_header("accept", mime_type()) 15 | |> ContentTypeNegotiation.call([]) 16 | 17 | refute conn.halted 18 | end 19 | 20 | test "halts and returns an error if no content-type or accept header" do 21 | conn = 22 | :post 23 | |> conn("/example", "") 24 | |> ContentTypeNegotiation.call([]) 25 | 26 | refute conn.halted 27 | end 28 | 29 | test "passes request through if only content-type header" do 30 | conn = 31 | :post 32 | |> conn("/example", "") 33 | |> Plug.Conn.put_req_header("content-type", mime_type()) 34 | |> ContentTypeNegotiation.call([]) 35 | 36 | refute conn.halted 37 | end 38 | 39 | test "passes request through if only accept header" do 40 | conn = 41 | :post 42 | |> conn("/example", "") 43 | |> Plug.Conn.put_req_header("accept", mime_type()) 44 | |> ContentTypeNegotiation.call([]) 45 | 46 | refute conn.halted 47 | end 48 | 49 | test "passes request through if multiple accept header" do 50 | conn = 51 | :post 52 | |> conn("/example", "") 53 | |> Plug.Conn.put_req_header( 54 | "accept", 55 | "#{mime_type()}, #{mime_type()}; version=1.0" 56 | ) 57 | |> ContentTypeNegotiation.call([]) 58 | 59 | refute conn.halted 60 | end 61 | 62 | test "passes request through if correct content-type header is last" do 63 | conn = 64 | :post 65 | |> conn("/example", "") 66 | |> Plug.Conn.put_req_header( 67 | "content-type", 68 | "#{mime_type()}, #{mime_type()}; version=1.0" 69 | ) 70 | |> ContentTypeNegotiation.call([]) 71 | 72 | refute conn.halted 73 | end 74 | 75 | test "passes request through if correct accept header is last" do 76 | conn = 77 | :post 78 | |> conn("/example", "") 79 | |> Plug.Conn.put_req_header( 80 | "accept", 81 | "#{mime_type()}, #{mime_type()}; version=1.0" 82 | ) 83 | |> ContentTypeNegotiation.call([]) 84 | 85 | refute conn.halted 86 | end 87 | 88 | test "halts and returns an error if content-type header contains other media type" do 89 | conn = 90 | :post 91 | |> conn("/example", "") 92 | |> Plug.Conn.put_req_header("content-type", "text/html") 93 | |> ContentTypeNegotiation.call([]) 94 | 95 | assert conn.halted 96 | assert 415 == conn.status 97 | 98 | assert conn.resp_body =~ 99 | ~s|The content-type header must use the media type 'application/vnd.api+json'| 100 | end 101 | 102 | test "halts and returns an error if content-type header contains other media type params" do 103 | conn = 104 | :post 105 | |> conn("/example", "") 106 | |> Plug.Conn.put_req_header("content-type", "#{mime_type()}; version=1.0") 107 | |> ContentTypeNegotiation.call([]) 108 | 109 | assert conn.halted 110 | assert 415 == conn.status 111 | end 112 | 113 | test "halts and returns an error if content-type header contains other media type params (multiple)" do 114 | conn = 115 | :post 116 | |> conn("/example", "") 117 | |> Plug.Conn.put_req_header( 118 | "content-type", 119 | "#{mime_type()}; version=1.0, #{mime_type()}; version=1.0" 120 | ) 121 | |> ContentTypeNegotiation.call([]) 122 | 123 | assert conn.halted 124 | assert 415 == conn.status 125 | end 126 | 127 | test "halts and returns an error if content-type header contains other media type params with correct accept header" do 128 | conn = 129 | :post 130 | |> conn("/example", "") 131 | |> Plug.Conn.put_req_header("content-type", "#{mime_type()}; version=1.0") 132 | |> Plug.Conn.put_req_header("accept", "#{mime_type()}") 133 | |> ContentTypeNegotiation.call([]) 134 | 135 | assert conn.halted 136 | assert 415 == conn.status 137 | end 138 | 139 | test "halts and returns an error if accept header contains other media type params" do 140 | conn = 141 | :post 142 | |> conn("/example", "") 143 | |> Plug.Conn.put_req_header("content-type", mime_type()) 144 | |> Plug.Conn.put_req_header("accept", "#{mime_type()} charset=utf-8") 145 | |> ContentTypeNegotiation.call([]) 146 | 147 | assert conn.halted 148 | assert 406 == conn.status 149 | end 150 | 151 | test "halts and returns an error if all accept header media types contain media type params with no content-type" do 152 | conn = 153 | :post 154 | |> conn("/example", "") 155 | |> Plug.Conn.put_req_header( 156 | "accept", 157 | "#{mime_type()}; version=1.0, #{mime_type()}; version=1.0" 158 | ) 159 | |> ContentTypeNegotiation.call([]) 160 | 161 | assert conn.halted 162 | assert 406 == conn.status 163 | end 164 | 165 | test "halts and returns an error if all accept header media types contain media type params" do 166 | conn = 167 | :post 168 | |> conn("/example", "") 169 | |> Plug.Conn.put_req_header("content-type", mime_type()) 170 | |> Plug.Conn.put_req_header( 171 | "accept", 172 | "#{mime_type()}; version=1.0, #{mime_type()}; version=1.0" 173 | ) 174 | |> ContentTypeNegotiation.call([]) 175 | 176 | assert conn.halted 177 | assert 406 == conn.status 178 | end 179 | 180 | test "returned error has correct content type" do 181 | conn = 182 | :post 183 | |> conn("/example", "") 184 | |> Plug.Conn.put_req_header( 185 | "accept", 186 | "#{mime_type()}; version=1.0, #{mime_type()}; version=1.0" 187 | ) 188 | |> ContentTypeNegotiation.call([]) 189 | 190 | assert conn.halted 191 | 192 | assert Plug.Conn.get_resp_header(conn, "content-type") == [ 193 | "#{mime_type()}; charset=utf-8" 194 | ] 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /test/jsonapi/plugs/deserializer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.DeserializerTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | @ct JSONAPI.mime_type() 6 | 7 | defmodule ExamplePlug do 8 | use Plug.Builder 9 | plug(Plug.Parsers, parsers: [:json], json_decoder: Jason) 10 | plug(JSONAPI.Deserializer) 11 | plug(:return) 12 | 13 | def return(conn, _opts) do 14 | send_resp(conn, 200, "success") 15 | end 16 | end 17 | 18 | test "Ignores bodyless requests" do 19 | conn = 20 | "GET" 21 | |> Plug.Test.conn("/") 22 | |> put_req_header("content-type", @ct) 23 | |> put_req_header("accept", @ct) 24 | 25 | result = ExamplePlug.call(conn, []) 26 | assert result.params == %{} 27 | end 28 | 29 | test "ignores non-jsonapi.org format params" do 30 | req_body = Jason.encode!(%{"some-nonsense" => "yup"}) 31 | 32 | conn = 33 | "POST" 34 | |> Plug.Test.conn("/", req_body) 35 | |> put_req_header("content-type", @ct) 36 | |> put_req_header("accept", @ct) 37 | 38 | result = ExamplePlug.call(conn, []) 39 | assert result.params == %{"some-nonsense" => "yup"} 40 | end 41 | 42 | test "works with basic list of data" do 43 | req_body = 44 | Jason.encode!(%{ 45 | "data" => [ 46 | %{"id" => "1", "type" => "car"}, 47 | %{"id" => "2", "type" => "car"} 48 | ] 49 | }) 50 | 51 | conn = 52 | "POST" 53 | |> Plug.Test.conn("/", req_body) 54 | |> put_req_header("content-type", @ct) 55 | |> put_req_header("accept", @ct) 56 | 57 | result = ExamplePlug.call(conn, []) 58 | 59 | assert result.params == [ 60 | %{"id" => "1", "type" => "car"}, 61 | %{"id" => "2", "type" => "car"} 62 | ] 63 | end 64 | 65 | test "deserializes attribute key names" do 66 | req_body = 67 | Jason.encode!(%{ 68 | "data" => %{ 69 | "attributes" => %{ 70 | "some-nonsense" => true, 71 | "foo-bar" => true, 72 | "some-map" => %{ 73 | "nested-key" => true 74 | } 75 | }, 76 | "relationships" => %{ 77 | "baz" => %{ 78 | "data" => %{ 79 | "id" => "2", 80 | "type" => "baz" 81 | } 82 | } 83 | } 84 | }, 85 | "filter" => %{ 86 | "dog-breed" => "Corgi" 87 | } 88 | }) 89 | 90 | conn = 91 | "POST" 92 | |> Plug.Test.conn("/", req_body) 93 | |> put_req_header("content-type", @ct) 94 | |> put_req_header("accept", @ct) 95 | 96 | result = ExamplePlug.call(conn, []) 97 | assert result.params["some-nonsense"] == true 98 | assert result.params["some-map"]["nested-key"] == true 99 | assert result.params["baz-id"] == "2" 100 | 101 | # Preserves query params 102 | assert result.params["filter"]["dog-breed"] == "Corgi" 103 | end 104 | 105 | describe "underscore" do 106 | defmodule ExampleUnderscorePlug do 107 | use Plug.Builder 108 | plug(Plug.Parsers, parsers: [:json], json_decoder: Jason) 109 | plug(JSONAPI.Deserializer) 110 | plug(JSONAPI.UnderscoreParameters) 111 | 112 | plug(:return) 113 | 114 | def return(conn, _opts) do 115 | send_resp(conn, 200, "success") 116 | end 117 | end 118 | 119 | test "deserializes attribute key names and underscores them" do 120 | req_body = 121 | Jason.encode!(%{ 122 | "data" => %{ 123 | "attributes" => %{ 124 | "some-nonsense" => true, 125 | "foo-bar" => true, 126 | "some-map" => %{ 127 | "nested-key" => true 128 | } 129 | }, 130 | "relationships" => %{ 131 | "baz" => %{ 132 | "data" => %{ 133 | "id" => "2", 134 | "type" => "baz" 135 | } 136 | } 137 | } 138 | } 139 | }) 140 | 141 | conn = 142 | "POST" 143 | |> Plug.Test.conn("/", req_body) 144 | |> put_req_header("content-type", @ct) 145 | |> put_req_header("accept", @ct) 146 | 147 | result = ExampleUnderscorePlug.call(conn, []) 148 | assert result.params["some_nonsense"] == true 149 | assert result.params["some_map"]["nested_key"] == true 150 | assert result.params["baz_id"] == "2" 151 | end 152 | end 153 | 154 | describe "camelize" do 155 | setup do 156 | Application.put_env(:jsonapi, :field_transformation, :camelize) 157 | 158 | on_exit(fn -> 159 | Application.delete_env(:jsonapi, :field_transformation) 160 | end) 161 | 162 | {:ok, []} 163 | end 164 | 165 | defmodule ExampleCamelCasePlug do 166 | use Plug.Builder 167 | plug(Plug.Parsers, parsers: [:json], json_decoder: Jason) 168 | plug(JSONAPI.Deserializer) 169 | 170 | plug(:return) 171 | 172 | def return(conn, _opts) do 173 | send_resp(conn, 200, "success") 174 | end 175 | end 176 | 177 | test "deserializes attribute key names and underscores them" do 178 | req_body = 179 | Jason.encode!(%{ 180 | "data" => %{ 181 | "attributes" => %{ 182 | "someNonsense" => true, 183 | "fooBar" => true, 184 | "someMap" => %{ 185 | "nested_key" => true 186 | } 187 | }, 188 | "relationships" => %{ 189 | "baz" => %{ 190 | "data" => %{ 191 | "id" => "2", 192 | "type" => "baz" 193 | } 194 | } 195 | } 196 | } 197 | }) 198 | 199 | conn = 200 | "POST" 201 | |> Plug.Test.conn("/", req_body) 202 | |> put_req_header("content-type", @ct) 203 | |> put_req_header("accept", @ct) 204 | 205 | result = ExampleCamelCasePlug.call(conn, []) 206 | assert result.params["someNonsense"] == true 207 | assert result.params["someMap"]["nested_key"] == true 208 | assert result.params["bazId"] == "2" 209 | end 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /lib/jsonapi/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.ErrorView do 2 | @moduledoc """ 3 | """ 4 | 5 | import Plug.Conn, only: [send_resp: 3, halt: 1, put_resp_content_type: 2] 6 | 7 | @crud_message "Check out http://jsonapi.org/format/#crud for more info." 8 | @relationship_resource_linkage_message "Check out https://jsonapi.org/format/#document-resource-object-linkage for more info." 9 | 10 | @type error_attrs :: map() 11 | 12 | @spec build_error(binary(), pos_integer(), binary() | nil, binary() | nil) :: error_attrs() 13 | def build_error(title, status, detail, pointer \\ nil, meta \\ nil) do 14 | error = %{ 15 | detail: detail, 16 | status: Integer.to_string(status), 17 | title: title 18 | } 19 | 20 | error 21 | |> append_field(:source, pointer) 22 | |> append_field(:meta, meta) 23 | end 24 | 25 | @spec malformed_id :: map() 26 | def malformed_id do 27 | "Malformed id in data parameter" 28 | |> build_error(422, @crud_message, "/data/id") 29 | |> serialize_error 30 | end 31 | 32 | @spec mismatched_id :: map() 33 | def mismatched_id do 34 | "Mismatched id parameter" 35 | |> build_error( 36 | 409, 37 | "The id in the url must match the id at '/data/id'. #{@crud_message}", 38 | "/data/id" 39 | ) 40 | |> serialize_error 41 | end 42 | 43 | @spec missing_data_attributes_param :: map() 44 | def missing_data_attributes_param do 45 | "Missing attributes in data parameter" 46 | |> build_error(400, @crud_message, "/data/attributes") 47 | |> serialize_error 48 | end 49 | 50 | @spec missing_data_id_param :: map() 51 | def missing_data_id_param do 52 | "Missing id in data parameter" 53 | |> build_error(400, @crud_message, "/data/id") 54 | |> serialize_error 55 | end 56 | 57 | @spec missing_data_type_param :: map() 58 | def missing_data_type_param do 59 | "Missing type in data parameter" 60 | |> build_error(400, @crud_message, "/data/type") 61 | |> serialize_error 62 | end 63 | 64 | @spec missing_data_param :: map() 65 | def missing_data_param do 66 | "Missing data parameter" 67 | |> build_error(400, @crud_message, "/data") 68 | |> serialize_error 69 | end 70 | 71 | @spec missing_id :: map() 72 | def missing_id do 73 | "Missing id in data parameter" 74 | |> build_error(400, @crud_message, "/data/id") 75 | |> serialize_error 76 | end 77 | 78 | @spec to_many_relationships_payload_for_standard_endpoint :: map() 79 | def to_many_relationships_payload_for_standard_endpoint do 80 | "Data parameter has multiple Resource Identifier Objects for a non-relationship endpoint" 81 | |> build_error( 82 | 400, 83 | "Check out https://jsonapi.org/format/#crud-updating-to-many-relationships for more info.", 84 | "/data" 85 | ) 86 | |> serialize_error 87 | end 88 | 89 | @spec incorrect_content_type :: map() 90 | def incorrect_content_type do 91 | detail = 92 | "The content-type header must use the media type '#{JSONAPI.mime_type()}'. #{@crud_message}" 93 | 94 | "Incorrect content-type" 95 | |> build_error(415, detail) 96 | |> serialize_error 97 | end 98 | 99 | @spec relationships_missing_object :: map() 100 | def relationships_missing_object do 101 | "Relationships parameter is not an object" 102 | |> build_error( 103 | 400, 104 | "Check out https://jsonapi.org/format/#document-resource-object-relationships for more info.", 105 | "/data/relationships" 106 | ) 107 | |> serialize_error 108 | end 109 | 110 | @spec missing_relationship_data_param_error_attrs(binary()) :: error_attrs() 111 | def missing_relationship_data_param_error_attrs(relationship_name) do 112 | build_error( 113 | "Missing data member in relationship", 114 | 400, 115 | "Check out https://jsonapi.org/format/#crud-creating and https://jsonapi.org/format/#crud-updating-resource-relationships for more info.", 116 | "/data/relationships/#{relationship_name}/data" 117 | ) 118 | end 119 | 120 | @spec missing_relationship_data_id_param_error_attrs(binary()) :: error_attrs() 121 | def missing_relationship_data_id_param_error_attrs(relationship_name) do 122 | build_error( 123 | "Missing id in relationship data parameter", 124 | 400, 125 | @relationship_resource_linkage_message, 126 | "/data/relationships/#{relationship_name}/data/id" 127 | ) 128 | end 129 | 130 | @spec missing_relationship_data_type_param_error_attrs(binary()) :: error_attrs() 131 | def missing_relationship_data_type_param_error_attrs(relationship_name) do 132 | build_error( 133 | "Missing type in relationship data parameter", 134 | 400, 135 | @relationship_resource_linkage_message, 136 | "/data/relationships/#{relationship_name}/data/type" 137 | ) 138 | end 139 | 140 | @spec send_error(Plug.Conn.t(), term()) :: term() 141 | def send_error(conn, %{errors: [%{status: status}]} = error), 142 | do: send_error(conn, status, error) 143 | 144 | def send_error(conn, %{errors: errors} = error) when is_list(errors) do 145 | status = 146 | errors 147 | |> Enum.max_by(&Map.get(&1, :status)) 148 | |> Map.get(:status) 149 | 150 | send_error(conn, status, error) 151 | end 152 | 153 | def send_error(conn, status, error \\ "") 154 | 155 | def send_error(conn, status, error) when is_map(error) do 156 | json = JSONAPI.json_library().encode!(error) 157 | send_error(conn, status, json) 158 | end 159 | 160 | def send_error(conn, status, error) when is_binary(status) do 161 | send_error(conn, String.to_integer(status), error) 162 | end 163 | 164 | def send_error(conn, status, error) do 165 | conn 166 | |> put_resp_content_type(JSONAPI.mime_type()) 167 | |> send_resp(status, error) 168 | |> halt 169 | end 170 | 171 | @spec serialize_error(error_attrs()) :: map() 172 | def serialize_error(error) do 173 | error = extract_error(error) 174 | %{errors: [error]} 175 | end 176 | 177 | @spec serialize_errors(list()) :: map() 178 | def serialize_errors(errors) do 179 | extracted = Enum.map(errors, &extract_error/1) 180 | %{errors: extracted} 181 | end 182 | 183 | defp extract_error(error) do 184 | Map.take(error, [:detail, :id, :links, :meta, :source, :status, :title]) 185 | end 186 | 187 | defp append_field(error, _field, nil), do: error 188 | defp append_field(error, :meta, value), do: Map.put(error, :meta, %{meta: value}) 189 | defp append_field(error, :source, value), do: Map.put(error, :source, %{pointer: value}) 190 | end 191 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"}, 4 | "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, 5 | "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"}, 6 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 7 | "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, 8 | "ex_doc": {:hex, :ex_doc, "0.39.2", "da5549bbce34c5fb0811f829f9f6b7a13d5607b222631d9e989447096f295c57", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "62665526a88c207653dbcee2aac66c2c229d7c18a70ca4ffc7f74f9e01324daa"}, 9 | "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, 10 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 11 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 12 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 13 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 14 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 15 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 16 | "phoenix": {:hex, :phoenix, "1.8.2", "75aba5b90081d88a54f2fc6a26453d4e76762ab095ff89be5a3e7cb28bff9300", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "19ea65b4064f17b1ab0515595e4d0ea65742ab068259608d5d7b139a73f47611"}, 17 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"}, 18 | "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, 19 | "plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"}, 20 | "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, 21 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 22 | "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, 23 | "websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"}, 24 | } 25 | -------------------------------------------------------------------------------- /test/utils/data_to_params_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.DataToParamsTest do 2 | use ExUnit.Case 3 | 4 | alias JSONAPI.Utils.DataToParams 5 | 6 | test "converts attributes and relationships to flattened data structure" do 7 | incoming = %{ 8 | "data" => %{ 9 | "id" => "1", 10 | "type" => "user", 11 | "attributes" => %{ 12 | "foo-bar" => true 13 | }, 14 | "relationships" => %{ 15 | "baz" => %{ 16 | "data" => %{ 17 | "id" => "2", 18 | "type" => "baz" 19 | } 20 | }, 21 | "boo" => %{ 22 | "data" => nil 23 | } 24 | } 25 | } 26 | } 27 | 28 | result = DataToParams.process(incoming) 29 | 30 | assert result == %{ 31 | "id" => "1", 32 | "type" => "user", 33 | "foo-bar" => true, 34 | "baz-id" => "2", 35 | "boo-id" => nil 36 | } 37 | end 38 | 39 | test "converts to many relationship" do 40 | incoming = %{ 41 | "data" => %{ 42 | "id" => "1", 43 | "type" => "user", 44 | "attributes" => %{ 45 | "foo-bar" => true 46 | }, 47 | "relationships" => %{ 48 | "baz" => %{ 49 | "data" => [ 50 | %{"id" => "2", "type" => "baz"}, 51 | %{"id" => "3", "type" => "baz"} 52 | ] 53 | } 54 | } 55 | } 56 | } 57 | 58 | result = DataToParams.process(incoming) 59 | 60 | assert result == %{ 61 | "id" => "1", 62 | "type" => "user", 63 | "foo-bar" => true, 64 | "baz-id" => ["2", "3"] 65 | } 66 | end 67 | 68 | test "converts polymorphic" do 69 | incoming = %{ 70 | "data" => %{ 71 | "id" => "1", 72 | "type" => "user", 73 | "attributes" => %{ 74 | "foo-bar" => true 75 | }, 76 | "relationships" => %{ 77 | "baz" => %{ 78 | "data" => [ 79 | %{"id" => "2", "type" => "baz"}, 80 | %{"id" => "3", "type" => "yooper"} 81 | ] 82 | } 83 | } 84 | } 85 | } 86 | 87 | result = DataToParams.process(incoming) 88 | 89 | assert result == %{ 90 | "id" => "1", 91 | "type" => "user", 92 | "foo-bar" => true, 93 | "baz-id" => "2", 94 | "yooper-id" => "3" 95 | } 96 | end 97 | 98 | test "processes single includes" do 99 | incoming = %{ 100 | "data" => %{ 101 | "id" => "1", 102 | "type" => "user", 103 | "attributes" => %{ 104 | "name" => "Jerome" 105 | } 106 | }, 107 | "included" => [ 108 | %{ 109 | "attributes" => %{ 110 | "name" => "Tara" 111 | }, 112 | "id" => "234", 113 | "type" => "friend" 114 | } 115 | ] 116 | } 117 | 118 | result = DataToParams.process(incoming) 119 | 120 | assert result == %{ 121 | "friend" => [ 122 | %{ 123 | "name" => "Tara", 124 | "id" => "234", 125 | "type" => "friend" 126 | } 127 | ], 128 | "id" => "1", 129 | "type" => "user", 130 | "name" => "Jerome" 131 | } 132 | end 133 | 134 | test "processes has many includes" do 135 | incoming = %{ 136 | "data" => %{ 137 | "id" => "1", 138 | "type" => "user", 139 | "attributes" => %{ 140 | "name" => "Jerome" 141 | } 142 | }, 143 | "included" => [ 144 | %{ 145 | "id" => "234", 146 | "type" => "friend", 147 | "attributes" => %{ 148 | "name" => "Tara" 149 | }, 150 | "relationships" => %{ 151 | "baz" => %{ 152 | "data" => %{ 153 | "id" => "2", 154 | "type" => "baz" 155 | } 156 | }, 157 | "boo" => %{ 158 | "data" => nil 159 | } 160 | } 161 | }, 162 | %{ 163 | "attributes" => %{ 164 | "name" => "Wild Bill" 165 | }, 166 | "id" => "0012", 167 | "type" => "friend" 168 | }, 169 | %{ 170 | "attributes" => %{ 171 | "title" => "Sr" 172 | }, 173 | "id" => "456", 174 | "type" => "organization" 175 | } 176 | ] 177 | } 178 | 179 | result = DataToParams.process(incoming) 180 | 181 | assert result == %{ 182 | "friend" => [ 183 | %{ 184 | "name" => "Wild Bill", 185 | "id" => "0012", 186 | "type" => "friend" 187 | }, 188 | %{ 189 | "name" => "Tara", 190 | "id" => "234", 191 | "type" => "friend", 192 | "baz-id" => "2", 193 | "boo-id" => nil 194 | } 195 | ], 196 | "organization" => [ 197 | %{ 198 | "title" => "Sr", 199 | "id" => "456", 200 | "type" => "organization" 201 | } 202 | ], 203 | "id" => "1", 204 | "type" => "user", 205 | "name" => "Jerome" 206 | } 207 | end 208 | 209 | test "processes simple array of data" do 210 | incoming = %{ 211 | "data" => [ 212 | %{"id" => "1", "type" => "user"}, 213 | %{"id" => "2", "type" => "user"} 214 | ] 215 | } 216 | 217 | result = DataToParams.process(incoming) 218 | 219 | assert result == [ 220 | %{"id" => "1", "type" => "user"}, 221 | %{"id" => "2", "type" => "user"} 222 | ] 223 | end 224 | 225 | test "processes empty keys" do 226 | incoming = %{ 227 | "data" => %{ 228 | "id" => "1", 229 | "type" => "user", 230 | "attributes" => nil 231 | }, 232 | "relationships" => nil, 233 | "included" => nil 234 | } 235 | 236 | result = DataToParams.process(incoming) 237 | 238 | assert result == %{ 239 | "id" => "1", 240 | "type" => "user" 241 | } 242 | end 243 | 244 | test "processes empty data" do 245 | incoming = %{ 246 | "data" => %{ 247 | "id" => "1", 248 | "type" => "user" 249 | } 250 | } 251 | 252 | result = DataToParams.process(incoming) 253 | 254 | assert result == %{ 255 | "id" => "1", 256 | "type" => "user" 257 | } 258 | end 259 | 260 | test "processes nil data" do 261 | incoming = %{ 262 | "data" => nil 263 | } 264 | 265 | result = DataToParams.process(incoming) 266 | 267 | assert result == nil 268 | end 269 | end 270 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file is synced with beam-community/common-config. Any changes will be overwritten. 2 | 3 | # This file contains the configuration for Credo and you are probably reading 4 | # this after creating it with `mix credo.gen.config`. 5 | # 6 | # If you find anything wrong or unclear in this file, please report an 7 | # issue on GitHub: https://github.com/rrrene/credo/issues 8 | # 9 | %{ 10 | # 11 | # You can have as many configs as you like in the `configs:` field. 12 | configs: [ 13 | %{ 14 | # 15 | # Run any config using `mix credo -C `. If no config name is given 16 | # "default" is used. 17 | # 18 | name: "default", 19 | # 20 | # These are the files included in the analysis: 21 | files: %{ 22 | # 23 | # You can give explicit globs or simply directories. 24 | # In the latter case `**/*.{ex,exs}` will be used. 25 | # 26 | included: ["config/", "lib/", "priv/", "test/"], 27 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 28 | }, 29 | # 30 | # Load and configure plugins here: 31 | # 32 | plugins: [], 33 | # 34 | # If you create your own checks, you must specify the source files for 35 | # them here, so they can be loaded by Credo before running the analysis. 36 | # 37 | requires: [], 38 | # 39 | # If you want to enforce a style guide and need a more traditional linting 40 | # experience, you can change `strict` to `true` below: 41 | # 42 | strict: true, 43 | # 44 | # To modify the timeout for parsing files, change this value: 45 | # 46 | parse_timeout: 5000, 47 | # 48 | # If you want to use uncolored output by default, you can change `color` 49 | # to `false` below: 50 | # 51 | color: true, 52 | # 53 | # You can customize the parameters of any check by adding a second element 54 | # to the tuple. 55 | # 56 | # To disable a check put `false` as second element: 57 | # 58 | # {Credo.Check.Design.DuplicatedCode, false} 59 | # 60 | checks: [ 61 | # 62 | ## Consistency Checks 63 | # 64 | {Credo.Check.Consistency.ExceptionNames, []}, 65 | {Credo.Check.Consistency.LineEndings, []}, 66 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 67 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 68 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 69 | {Credo.Check.Consistency.SpaceInParentheses, []}, 70 | {Credo.Check.Consistency.TabsOrSpaces, []}, 71 | {Credo.Check.Consistency.UnusedVariableNames, false}, 72 | 73 | # 74 | ## Design Checks 75 | # 76 | # You can customize the priority of any check 77 | # Priority values are: `low, normal, high, higher` 78 | # 79 | {Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 2]}, 80 | {Credo.Check.Design.DuplicatedCode, false}, 81 | # You can also customize the exit_status of each check. 82 | # If you don't want TODO comments to cause `mix credo` to fail, just 83 | # set this value to 0 (zero). 84 | # 85 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 86 | {Credo.Check.Design.TagFIXME, []}, 87 | 88 | # 89 | ## Readability Checks 90 | # 91 | {Credo.Check.Readability.AliasAs, false}, 92 | {Credo.Check.Readability.AliasOrder, []}, 93 | {Credo.Check.Readability.BlockPipe, []}, 94 | {Credo.Check.Readability.FunctionNames, []}, 95 | {Credo.Check.Readability.ImplTrue, []}, 96 | {Credo.Check.Readability.LargeNumbers, [trailing_digits: 2]}, 97 | {Credo.Check.Readability.MaxLineLength, false}, 98 | {Credo.Check.Readability.ModuleAttributeNames, []}, 99 | {Credo.Check.Readability.ModuleDoc, false}, 100 | {Credo.Check.Readability.ModuleNames, []}, 101 | {Credo.Check.Readability.MultiAlias, false}, 102 | {Credo.Check.Readability.NestedFunctionCalls, []}, 103 | {Credo.Check.Readability.ParenthesesInCondition, []}, 104 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 105 | {Credo.Check.Readability.PredicateFunctionNames, []}, 106 | {Credo.Check.Readability.PreferImplicitTry, []}, 107 | {Credo.Check.Readability.RedundantBlankLines, []}, 108 | {Credo.Check.Readability.Semicolons, []}, 109 | {Credo.Check.Readability.SeparateAliasRequire, []}, 110 | {Credo.Check.Readability.SinglePipe, []}, 111 | {Credo.Check.Readability.SpaceAfterCommas, []}, 112 | {Credo.Check.Readability.Specs, false}, 113 | {Credo.Check.Readability.StrictModuleLayout, 114 | [ 115 | order: 116 | ~w(moduledoc behaviour use import require alias module_attribute defstruct callback macrocallback optional_callback)a, 117 | ignore: [:type], 118 | ignore_module_attributes: [:tag, :trace] 119 | ]}, 120 | {Credo.Check.Readability.StringSigils, []}, 121 | {Credo.Check.Readability.TrailingBlankLine, []}, 122 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 123 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 124 | {Credo.Check.Readability.VariableNames, []}, 125 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 126 | 127 | # 128 | ## Refactoring Opportunities 129 | # 130 | {Credo.Check.Refactor.ABCSize, false}, 131 | {Credo.Check.Refactor.AppendSingleItem, []}, 132 | {Credo.Check.Refactor.CondStatements, []}, 133 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 134 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 135 | {Credo.Check.Refactor.FunctionArity, []}, 136 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 137 | # {Credo.Check.Refactor.MapInto, []}, 138 | {Credo.Check.Refactor.MatchInCondition, []}, 139 | {Credo.Check.Refactor.ModuleDependencies, false}, 140 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 141 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 142 | {Credo.Check.Refactor.NegatedIsNil, []}, 143 | {Credo.Check.Refactor.Nesting, []}, 144 | {Credo.Check.Refactor.PipeChainStart, []}, 145 | {Credo.Check.Refactor.UnlessWithElse, []}, 146 | {Credo.Check.Refactor.VariableRebinding, false}, 147 | {Credo.Check.Refactor.WithClauses, []}, 148 | 149 | # 150 | ## Warnings 151 | # 152 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 153 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 154 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 155 | {Credo.Check.Warning.IExPry, []}, 156 | {Credo.Check.Warning.IoInspect, []}, 157 | {Credo.Check.Warning.LeakyEnvironment, []}, 158 | # {Credo.Check.Warning.LazyLogging, []}, 159 | {Credo.Check.Warning.MapGetUnsafePass, []}, 160 | # disabling this check by default, as if not included, it will be 161 | # run on version 1.7.0 and above 162 | {Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, []}, 163 | {Credo.Check.Warning.MixEnv, []}, 164 | {Credo.Check.Warning.OperationOnSameValues, []}, 165 | {Credo.Check.Warning.OperationWithConstantResult, []}, 166 | {Credo.Check.Warning.RaiseInsideRescue, []}, 167 | {Credo.Check.Warning.UnsafeExec, []}, 168 | {Credo.Check.Warning.UnsafeToAtom, []}, 169 | {Credo.Check.Warning.UnusedEnumOperation, []}, 170 | {Credo.Check.Warning.UnusedFileOperation, []}, 171 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 172 | {Credo.Check.Warning.UnusedListOperation, []}, 173 | {Credo.Check.Warning.UnusedPathOperation, []}, 174 | {Credo.Check.Warning.UnusedRegexOperation, []}, 175 | {Credo.Check.Warning.UnusedStringOperation, []}, 176 | {Credo.Check.Warning.UnusedTupleOperation, []} 177 | ] 178 | } 179 | ] 180 | } 181 | -------------------------------------------------------------------------------- /test/jsonapi/plugs/query_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.QueryParserTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | import JSONAPI.QueryParser 6 | 7 | alias JSONAPI.Config 8 | alias JSONAPI.Exceptions.InvalidQuery 9 | 10 | defmodule MyView do 11 | use JSONAPI.View 12 | 13 | def fields, do: [:id, :text, :body] 14 | def type, do: "mytype" 15 | 16 | def relationships do 17 | [ 18 | author: JSONAPI.QueryParserTest.UserView, 19 | comments: JSONAPI.QueryParserTest.CommentView, 20 | best_friends: JSONAPI.QueryParserTest.UserView 21 | ] 22 | end 23 | end 24 | 25 | defmodule UserView do 26 | use JSONAPI.View 27 | 28 | def fields, do: [:id, :username] 29 | def type, do: "user" 30 | def relationships, do: [top_posts: MyView] 31 | end 32 | 33 | defmodule CommentView do 34 | use JSONAPI.View 35 | 36 | def fields, do: [:id, :text] 37 | def type, do: "comment" 38 | def relationships, do: [user: JSONAPI.QueryParserTest.UserView] 39 | end 40 | 41 | setup do 42 | Application.put_env(:jsonapi, :field_transformation, :underscore) 43 | 44 | on_exit(fn -> 45 | Application.delete_env(:jsonapi, :field_transformation) 46 | end) 47 | 48 | {:ok, []} 49 | end 50 | 51 | test "parse_sort/2 turns sorts into valid ecto sorts" do 52 | config = struct(Config, opts: [sort: ~w(name title)], view: MyView) 53 | assert parse_sort(config, "name,title").sort == [asc: :name, asc: :title] 54 | assert parse_sort(config, "name").sort == [asc: :name] 55 | assert parse_sort(config, "-name").sort == [desc: :name] 56 | assert parse_sort(config, "name,-title").sort == [asc: :name, desc: :title] 57 | end 58 | 59 | test "parse_sort/2 raises on invalid sorts" do 60 | config = struct(Config, opts: [], view: MyView) 61 | 62 | assert_raise InvalidQuery, "invalid sort, name for type mytype", fn -> 63 | parse_sort(config, "name") 64 | end 65 | end 66 | 67 | test "parse_filter/2 returns filters key/val pairs" do 68 | config = struct(Config, opts: [filter: ~w(name)], view: MyView) 69 | filter = parse_filter(config, %{"name" => "jason"}).filter 70 | assert filter[:name] == "jason" 71 | end 72 | 73 | test "parse_filter/2 handles nested filters" do 74 | config = struct(Config, opts: [filter: ~w(author.username)], view: MyView) 75 | filter = parse_filter(config, %{"author.username" => "jason"}).filter 76 | assert filter[:author][:username] == "jason" 77 | end 78 | 79 | test "parse_filter/2 handles nested filters two deep" do 80 | config = struct(Config, opts: [filter: ~w(author.top_posts.text)], view: MyView) 81 | filter = parse_filter(config, %{"author.top_posts.text" => "some post"}).filter 82 | assert filter[:author][:top_posts][:text] == "some post" 83 | end 84 | 85 | test "parse_filter/2 handles nested filters with overlap" do 86 | config = struct(Config, opts: [filter: ~w(author.username author.id)], view: MyView) 87 | filter = parse_filter(config, %{"author.username" => "jason", "author.id" => "123"}).filter 88 | assert filter[:author][:username] == "jason" 89 | assert filter[:author][:id] == "123" 90 | end 91 | 92 | test "parse_filter/2 raises on invalid filters" do 93 | config = struct(Config, opts: [], view: MyView) 94 | 95 | assert_raise InvalidQuery, "invalid filter, noop for type mytype", fn -> 96 | parse_filter(config, %{"noop" => "jason"}) 97 | end 98 | end 99 | 100 | test "parse_include/2 turns an include string into a keyword list" do 101 | config = struct(Config, view: MyView) 102 | assert parse_include(config, "author,comments.user").include == [:author, comments: :user] 103 | assert parse_include(config, "author").include == [:author] 104 | assert parse_include(config, "comments,author").include == [:comments, :author] 105 | assert parse_include(config, "comments.user").include == [comments: :user] 106 | assert parse_include(config, "comments.user.top_posts").include == [comments: [user: :top_posts]] 107 | assert parse_include(config, "best_friends").include == [:best_friends] 108 | assert parse_include(config, "author.top-posts").include == [author: :top_posts] 109 | assert parse_include(config, "").include == [] 110 | end 111 | 112 | test "parse_include/2 succeds given valid nested include specified in allowed list" do 113 | config = struct(Config, view: MyView, opts: [include: ~w(comments.user)]) 114 | 115 | assert parse_include(config, "comments.user").include == [comments: :user] 116 | end 117 | 118 | test "parse_include/2 succeds given valid twice-nested include specified in allowed list" do 119 | config = struct(Config, view: MyView, opts: [include: ~w(comments.user.top_posts)]) 120 | 121 | assert parse_include(config, "comments.user.top_posts").include == [comments: [user: :top_posts]] 122 | end 123 | 124 | test "parse_include/2 errors with invalid includes" do 125 | config = struct(Config, view: MyView) 126 | 127 | assert_raise InvalidQuery, "invalid include, user for type mytype", fn -> 128 | parse_include(config, "user,comments.author") 129 | end 130 | 131 | assert_raise InvalidQuery, "invalid include, comments.author for type mytype", fn -> 132 | parse_include(config, "comments.author") 133 | end 134 | 135 | assert_raise InvalidQuery, "invalid include, comments.author.user for type mytype", fn -> 136 | parse_include(config, "comments.author.user") 137 | end 138 | 139 | assert_raise InvalidQuery, "invalid include, fake_rel for type mytype", fn -> 140 | assert parse_include(config, "fake-rel") 141 | end 142 | end 143 | 144 | test "parse_include/2 errors with limited allowed includes" do 145 | config = struct(Config, view: MyView, opts: [include: ~w(author comments comments.user)]) 146 | 147 | assert_raise InvalidQuery, "invalid include, best_friends for type mytype", fn -> 148 | parse_include(config, "best_friends,author") 149 | end 150 | 151 | assert parse_include(config, "author,comments").include == [:author, :comments] 152 | 153 | assert parse_include(config, "author,comments.user").include == [:author, {:comments, :user}] 154 | end 155 | 156 | test "parse_fields/2 turns a fields map into a map of validated fields" do 157 | config = struct(Config, view: MyView) 158 | assert parse_fields(config, %{"mytype" => "id,text"}).fields == %{"mytype" => [:id, :text]} 159 | end 160 | 161 | test "parse_fields/2 turns an empty fields map into an empty list" do 162 | config = struct(Config, view: MyView) 163 | assert parse_fields(config, %{"mytype" => ""}).fields == %{"mytype" => []} 164 | end 165 | 166 | test "parse_fields/2 raises on invalid parsing" do 167 | config = struct(Config, view: MyView) 168 | 169 | assert_raise InvalidQuery, "invalid fields, blag for type mytype", fn -> 170 | parse_fields(config, %{"mytype" => "blag"}) 171 | end 172 | 173 | assert_raise InvalidQuery, "invalid fields, username for type mytype", fn -> 174 | parse_fields(config, %{"mytype" => "username"}) 175 | end 176 | end 177 | 178 | test "get_view_for_type/2 using view.type as key" do 179 | assert get_view_for_type(MyView, "comment") == JSONAPI.QueryParserTest.CommentView 180 | end 181 | 182 | test "DEPRECATED: get_view_for_type/2 using relationship name as key" do 183 | assert get_view_for_type(MyView, "comments") == JSONAPI.QueryParserTest.CommentView 184 | end 185 | 186 | test "parse_pagination/2 turns a fields map into a map of pagination values" do 187 | config = struct(Config, view: MyView) 188 | assert parse_pagination(config, config.page).page == %{} 189 | assert parse_pagination(config, %{"limit" => "1"}).page == %{"limit" => "1"} 190 | assert parse_pagination(config, %{"offset" => "1"}).page == %{"offset" => "1"} 191 | assert parse_pagination(config, %{"page" => "1"}).page == %{"page" => "1"} 192 | assert parse_pagination(config, %{"size" => "1"}).page == %{"size" => "1"} 193 | assert parse_pagination(config, %{"cursor" => "cursor"}).page == %{"cursor" => "cursor"} 194 | end 195 | 196 | test "get_view_for_type/2 raises on invalid fields" do 197 | assert_raise InvalidQuery, "invalid fields, cupcake for type mytype", fn -> 198 | get_view_for_type(MyView, "cupcake") 199 | end 200 | end 201 | 202 | test "integrates with UnderscoreParameters to filter dasherized fields" do 203 | # The incoming request has a dasherized filter name 204 | conn = 205 | :get 206 | |> conn("?filter[favorite-food]=pizza") 207 | |> put_req_header("content-type", JSONAPI.mime_type()) 208 | 209 | # The filter in the controller is expecting an underscored filter name 210 | config = struct(Config, view: MyView, opts: [filter: ["favorite_food"]]) 211 | 212 | conn = 213 | conn 214 | |> JSONAPI.UnderscoreParameters.call(replace_query_params: true) 215 | |> JSONAPI.QueryParser.call(config) 216 | 217 | # Ensure the underscored file name is present in the parsed filters 218 | assert [favorite_food: _] = conn.assigns.jsonapi_query.filter 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /lib/jsonapi/utils/string.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Utils.String do 2 | @moduledoc """ 3 | String manipulation helpers. 4 | """ 5 | 6 | @allowed_transformations [ 7 | :camelize, 8 | :dasherize, 9 | :underscore, 10 | :camelize_shallow, 11 | :dasherize_shallow 12 | ] 13 | 14 | @doc """ 15 | Replace dashes between words in `value` with underscores 16 | 17 | Ignores dashes that are not between letters/numbers 18 | 19 | ## Examples 20 | 21 | iex> underscore("top-posts") 22 | "top_posts" 23 | 24 | iex> underscore(:top_posts) 25 | "top_posts" 26 | 27 | iex> underscore("-top-posts") 28 | "-top_posts" 29 | 30 | iex> underscore("-top--posts-") 31 | "-top--posts-" 32 | 33 | iex> underscore("corgiAge") 34 | "corgi_age" 35 | 36 | iex> underscore("ages-0-17") 37 | "ages_0_17" 38 | 39 | """ 40 | @spec underscore(String.t()) :: String.t() 41 | def underscore(value) when is_binary(value) do 42 | value 43 | |> String.replace(~r/(?<=[a-zA-Z\d])-(?=[a-zA-Z\d])/, "_") 44 | |> String.replace(~r/([a-z\d])([A-Z])/, "\\1_\\2") 45 | |> String.downcase() 46 | end 47 | 48 | @spec underscore(atom) :: String.t() 49 | def underscore(value) when is_atom(value) do 50 | value 51 | |> to_string() 52 | |> underscore() 53 | end 54 | 55 | @doc """ 56 | Replace underscores between words in `value` with dashes 57 | 58 | Ignores underscores that are not between letters/numbers 59 | 60 | ## Examples 61 | 62 | iex> dasherize("top_posts") 63 | "top-posts" 64 | 65 | iex> dasherize("_top_posts") 66 | "_top-posts" 67 | 68 | iex> dasherize("_top__posts_") 69 | "_top__posts_" 70 | 71 | iex> dasherize("ages_0_17") 72 | "ages-0-17" 73 | 74 | """ 75 | @spec dasherize(atom) :: String.t() 76 | def dasherize(value) when is_atom(value) do 77 | value 78 | |> to_string() 79 | |> dasherize() 80 | end 81 | 82 | @spec dasherize(String.t()) :: String.t() 83 | def dasherize(value) when is_binary(value) do 84 | String.replace(value, ~r/(?<=[a-zA-Z0-9])_(?=[a-zA-Z0-9])/, "-") 85 | end 86 | 87 | @doc """ 88 | Replace underscores or dashes between words in `value` with camelCasing 89 | 90 | Ignores underscores or dashes that are not between letters/numbers 91 | 92 | ## Examples 93 | 94 | iex> camelize("top_posts") 95 | "topPosts" 96 | 97 | iex> camelize(:top_posts) 98 | "topPosts" 99 | 100 | iex> camelize("_top_posts") 101 | "_topPosts" 102 | 103 | iex> camelize("_top__posts_") 104 | "_top__posts_" 105 | 106 | iex> camelize("") 107 | "" 108 | 109 | iex> camelize("alreadyCamelized") 110 | "alreadyCamelized" 111 | 112 | iex> camelize("alreadyCamelized-id") 113 | "alreadyCamelizedId" 114 | 115 | iex> camelize("alreadyCamelized_id") 116 | "alreadyCamelizedId" 117 | 118 | iex> camelize("PascalLambda_id") 119 | "pascalLambdaId" 120 | 121 | """ 122 | @spec camelize(atom) :: String.t() 123 | def camelize(value) when is_atom(value) do 124 | value 125 | |> to_string() 126 | |> camelize() 127 | end 128 | 129 | @spec camelize(String.t()) :: String.t() 130 | def camelize(value) when value == "", do: value 131 | 132 | def camelize(value) when is_binary(value) do 133 | case Regex.split( 134 | ~r{(?<=[a-zA-Z0-9])[-_](?=[a-zA-Z0-9])}, 135 | to_string(value), 136 | trim: true 137 | ) do 138 | # If there is only one word, leave it as-is 139 | [word] -> 140 | word 141 | 142 | # If there are multiple words, perform the camelizing 143 | [h | t] -> 144 | {first_character, rest_word} = String.split_at(h, 1) 145 | first_word = String.downcase(first_character) <> rest_word 146 | Enum.join([first_word | camelize_list(t)]) 147 | end 148 | end 149 | 150 | defp camelize_list([]), do: [] 151 | 152 | defp camelize_list([h | t]) do 153 | [String.capitalize(h) | camelize_list(t)] 154 | end 155 | 156 | @doc """ 157 | 158 | ## Examples 159 | 160 | iex> expand_fields(%{"foo-bar" => "baz"}, &underscore/1) 161 | %{"foo_bar" => "baz"} 162 | 163 | iex> expand_fields(%{"foo_bar" => "baz"}, &dasherize/1) 164 | %{"foo-bar" => "baz"} 165 | 166 | iex> expand_fields(%{"foo-bar" => "baz"}, &camelize/1) 167 | %{"fooBar" => "baz"} 168 | 169 | iex> expand_fields({"foo-bar", "dollar-sol"}, &underscore/1) 170 | {"foo_bar", "dollar-sol"} 171 | 172 | iex> expand_fields({"foo-bar", %{"a-d" => "z-8"}}, &underscore/1) 173 | {"foo_bar", %{"a_d" => "z-8"}} 174 | 175 | iex> expand_fields(%{"f-b" => %{"a-d" => "z"}, "c-d" => "e"}, &underscore/1) 176 | %{"f_b" => %{"a_d" => "z"}, "c_d" => "e"} 177 | 178 | iex> expand_fields(%{"f-b" => %{"a-d" => %{"z-w" => "z"}}, "c-d" => "e"}, &underscore/1) 179 | %{"f_b" => %{"a_d" => %{"z_w" => "z"}}, "c_d" => "e"} 180 | 181 | iex> expand_fields(:"foo-bar", &underscore/1) 182 | "foo_bar" 183 | 184 | iex> expand_fields(:foo_bar, &dasherize/1) 185 | "foo-bar" 186 | 187 | iex> expand_fields(:"foo-bar", &camelize/1) 188 | "fooBar" 189 | 190 | iex> expand_fields(%{"f-b" => "a-d"}, &underscore/1) 191 | %{"f_b" => "a-d"} 192 | 193 | iex> expand_fields(%{"inserted-at" => ~N[2019-01-17 03:27:24.776957]}, &underscore/1) 194 | %{"inserted_at" => ~N[2019-01-17 03:27:24.776957]} 195 | 196 | iex> expand_fields(%{"xValue" => 123}, &underscore/1) 197 | %{"x_value" => 123} 198 | 199 | iex> expand_fields(%{"attributes" => %{"corgiName" => "Wardel"}}, &underscore/1) 200 | %{"attributes" => %{"corgi_name" => "Wardel"}} 201 | 202 | iex> expand_fields(%{"attributes" => %{"corgiName" => ["Wardel"]}}, &underscore/1) 203 | %{"attributes" => %{"corgi_name" => ["Wardel"]}} 204 | 205 | iex> expand_fields(%{"attributes" => %{"someField" => ["SomeValue", %{"nestedField" => "Value"}]}}, &underscore/1) 206 | %{"attributes" => %{"some_field" => ["SomeValue", %{"nested_field" => "Value"}]}} 207 | 208 | iex> expand_fields([%{"fooBar" => "a"}, %{"fooBar" => "b"}], &underscore/1) 209 | [%{"foo_bar" => "a"}, %{"foo_bar" => "b"}] 210 | 211 | iex> expand_fields([%{"foo_bar" => "a"}, %{"foo_bar" => "b"}], &camelize/1) 212 | [%{"fooBar" => "a"}, %{"fooBar" => "b"}] 213 | 214 | iex> expand_fields(%{"fooAttributes" => [%{"fooBar" => "a"}, %{"fooBar" => "b"}]}, &underscore/1) 215 | %{"foo_attributes" => [%{"foo_bar" => "a"}, %{"foo_bar" => "b"}]} 216 | 217 | iex> expand_fields(%{"foo_attributes" => [%{"foo_bar" => "a"}, %{"foo_bar" => "b"}]}, &camelize/1) 218 | %{"fooAttributes" => [%{"fooBar" => "a"}, %{"fooBar" => "b"}]} 219 | 220 | iex> expand_fields(%{"foo_attributes" => [%{"foo_bar" => [1, 2]}]}, &camelize/1) 221 | %{"fooAttributes" => [%{"fooBar" => [1, 2]}]} 222 | 223 | """ 224 | @spec expand_fields(map, function) :: map 225 | def expand_fields(%{__struct__: _} = value, _fun), do: value 226 | 227 | def expand_fields(map, fun) when is_map(map) do 228 | Enum.into(map, %{}, &expand_fields(&1, fun)) 229 | end 230 | 231 | @spec expand_fields(list, function) :: list 232 | def expand_fields(values, fun) when is_list(values) do 233 | Enum.map(values, &expand_fields(&1, fun)) 234 | end 235 | 236 | @spec expand_fields(tuple, function) :: tuple 237 | def expand_fields({key, value}, fun) when is_map(value) do 238 | {fun.(key), expand_fields(value, fun)} 239 | end 240 | 241 | def expand_fields({key, value}, fun) when is_list(value) do 242 | {fun.(key), maybe_expand_fields(value, fun)} 243 | end 244 | 245 | def expand_fields({key, value}, fun) do 246 | {fun.(key), value} 247 | end 248 | 249 | @spec expand_fields(String.t() | atom(), function) :: String.t() 250 | def expand_fields(value, fun) when is_binary(value) or is_atom(value) do 251 | fun.(value) 252 | end 253 | 254 | def expand_fields(value, _fun) do 255 | value 256 | end 257 | 258 | @doc """ 259 | Like `JSONAPI.Utils.String.expand_fields/2`, but only uses the given function to transform the 260 | keys of a top-level map. Other values are transformed with `to_string/1`. 261 | 262 | ## Examples 263 | 264 | iex> expand_root_keys(%{"foo-bar" => %{"bar-baz" => "x"}}, &underscore/1) 265 | %{"foo_bar" => %{"bar-baz" => "x"}} 266 | 267 | iex> expand_root_keys(%{"foo-bar" => [:x, %{"bar-baz" => "y"}]}, &underscore/1) 268 | %{"foo_bar" => ["x", %{"bar-baz" => "y"}]} 269 | 270 | """ 271 | def expand_root_keys(map, fun) when is_map(map) do 272 | Enum.into(map, %{}, fn {key, value} -> 273 | {fun.(key), expand_fields(value, &to_string/1)} 274 | end) 275 | end 276 | 277 | def expand_root_keys(key, fun) when is_binary(key) or is_atom(key) do 278 | fun.(key) 279 | end 280 | 281 | def expand_root_keys(value, _fun), do: expand_fields(value, &to_string/1) 282 | 283 | defp maybe_expand_fields(values, fun) when is_list(values) do 284 | Enum.map(values, fn 285 | string when is_binary(string) -> string 286 | value -> expand_fields(value, fun) 287 | end) 288 | end 289 | 290 | @doc """ 291 | The configured transformation for the API's fields. JSON:API v1.1 recommends 292 | using camlized fields (e.g. "goodDog", versus "good_dog"). However, we don't hold a strong 293 | opinion, so feel free to customize it how you would like (e.g. "good-dog", versus "good_dog"). 294 | 295 | This library currently supports camelized, dashed and underscored fields. Shallow variants 296 | exist that only transform top-level field keys. 297 | 298 | ## Configuration examples 299 | 300 | camelCase fields: 301 | 302 | ``` 303 | config :jsonapi, field_transformation: :camelize 304 | ``` 305 | 306 | ``` 307 | config :jsonapi, field_transformation: :camelize_shallow 308 | ``` 309 | 310 | Dashed fields: 311 | 312 | ``` 313 | config :jsonapi, field_transformation: :dasherize 314 | ``` 315 | 316 | ``` 317 | config :jsonapi, field_transformation: :dasherize_shallow 318 | ``` 319 | 320 | Underscored fields: 321 | 322 | ``` 323 | config :jsonapi, field_transformation: :underscore 324 | ``` 325 | """ 326 | def field_transformation do 327 | field_transformation(Application.get_env(:jsonapi, :field_transformation)) 328 | end 329 | 330 | @doc false 331 | def field_transformation(nil), do: nil 332 | 333 | def field_transformation(transformation) when transformation in @allowed_transformations, 334 | do: transformation 335 | end 336 | -------------------------------------------------------------------------------- /lib/jsonapi/serializer.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.Serializer do 2 | @moduledoc """ 3 | Serialize a map of data into a properly formatted JSON API response object 4 | """ 5 | require Logger 6 | 7 | alias JSONAPI.{Config, Utils, View} 8 | alias Plug.Conn 9 | 10 | @type document :: map() 11 | 12 | @doc """ 13 | Takes a view, data and a optional plug connection and returns a fully JSONAPI Serialized document. 14 | This assumes you are using the JSONAPI.View and have data in maps or structs. 15 | 16 | Please refer to `JSONAPI.View` for more information. If you are in interested in relationships 17 | and includes you may also want to reference the `JSONAPI.QueryParser`. 18 | """ 19 | @spec serialize(View.t(), View.data(), Conn.t() | nil, View.meta() | nil, View.options()) :: 20 | document() 21 | def serialize(view, data, conn \\ nil, meta \\ nil, options \\ []) do 22 | {query_includes, query_page} = 23 | case conn do 24 | %Conn{assigns: %{jsonapi_query: %Config{include: include, page: page}}} -> 25 | {include, page} 26 | 27 | _ -> 28 | {[], nil} 29 | end 30 | 31 | {to_include, encoded_data} = encode_data(view, data, conn, query_includes, options) 32 | 33 | encoded_data = %{ 34 | data: encoded_data, 35 | included: flatten_included(to_include) 36 | } 37 | 38 | encoded_data = 39 | if is_map(meta) do 40 | Map.put(encoded_data, :meta, meta) 41 | else 42 | encoded_data 43 | end 44 | 45 | merge_links(encoded_data, data, view, conn, query_page, remove_links?(), options) 46 | end 47 | 48 | def encode_data(_view, nil, _conn, _query_includes, _options), do: {[], nil} 49 | 50 | def encode_data(view, data, conn, query_includes, options) when is_list(data) do 51 | {to_include, encoded_data} = 52 | Enum.map_reduce(data, [], fn d, acc -> 53 | {to_include, encoded_data} = encode_data(view, d, conn, query_includes, options) 54 | {to_include, [encoded_data | acc]} 55 | end) 56 | 57 | {to_include, Enum.reverse(encoded_data)} 58 | end 59 | 60 | def encode_data(view, data, conn, query_includes, options) do 61 | valid_includes = get_includes(view, query_includes, data) 62 | 63 | encoded_data = %{ 64 | id: view.id(data), 65 | type: view.resource_type(data), 66 | attributes: transform_fields(view.attributes(data, conn)), 67 | relationships: %{} 68 | } 69 | 70 | doc = merge_links(encoded_data, data, view, conn, nil, remove_links?(), options) 71 | 72 | doc = 73 | case view.meta(data, conn) do 74 | nil -> doc 75 | meta -> Map.put(doc, :meta, meta) 76 | end 77 | 78 | encode_relationships(conn, doc, {view, data, query_includes, valid_includes}, options) 79 | end 80 | 81 | @spec encode_relationships(Conn.t(), document(), tuple(), list()) :: tuple() 82 | def encode_relationships(conn, doc, {view, data, _, _} = view_info, options) do 83 | data 84 | |> view.resource_relationships() 85 | |> Enum.filter(&assoc_loaded?(Map.get(data, get_data_key(&1)))) 86 | |> Enum.map_reduce(doc, &build_relationships(conn, view_info, &1, &2, options)) 87 | end 88 | 89 | defp get_data_key(rel_config), do: elem(extrapolate_relationship_config(rel_config), 1) 90 | 91 | @spec build_relationships(Conn.t(), tuple(), term(), term(), module(), tuple(), list()) :: 92 | tuple() 93 | def build_relationships( 94 | conn, 95 | {parent_view, parent_data, query_includes, valid_includes}, 96 | relationship_name, 97 | rel_data, 98 | rel_view, 99 | acc, 100 | options 101 | ) do 102 | # Build the relationship url 103 | rel_key = transform_fields(relationship_name) 104 | rel_url = parent_view.url_for_rel(parent_data, rel_key, conn) 105 | 106 | # Build the relationship 107 | acc = 108 | put_in( 109 | acc, 110 | [:relationships, rel_key], 111 | encode_relation({rel_view, rel_data, rel_url, conn}) 112 | ) 113 | 114 | valid_include_view = include_view(valid_includes, relationship_name) 115 | 116 | if {rel_view, :include} == valid_include_view && data_loaded?(rel_data) do 117 | rel_query_includes = 118 | if is_list(query_includes) do 119 | query_includes 120 | # credo:disable-for-next-line 121 | |> Enum.reduce([], fn 122 | {^relationship_name, value}, acc -> [value | acc] 123 | _, acc -> acc 124 | end) 125 | |> Enum.reverse() 126 | |> List.flatten() 127 | else 128 | [] 129 | end 130 | 131 | {rel_included, encoded_rel} = 132 | encode_data(rel_view, rel_data, conn, rel_query_includes, options) 133 | 134 | # credo:disable-for-next-line 135 | {rel_included ++ [encoded_rel], acc} 136 | else 137 | {nil, acc} 138 | end 139 | end 140 | 141 | @spec build_relationships(Conn.t(), tuple(), tuple(), tuple(), list()) :: tuple() 142 | def build_relationships( 143 | conn, 144 | {_parent_view, data, _query_includes, _valid_includes} = parent_info, 145 | rel_config, 146 | acc, 147 | options 148 | ) do 149 | {rewrite_key, data_key, rel_view, _include} = extrapolate_relationship_config(rel_config) 150 | 151 | rel_data = Map.get(data, data_key) 152 | 153 | build_relationships( 154 | conn, 155 | parent_info, 156 | rewrite_key, 157 | rel_data, 158 | rel_view, 159 | acc, 160 | options 161 | ) 162 | end 163 | 164 | @doc """ 165 | Given the relationship config entry provided by a JSONAPI.View, produce 166 | the extrapolated config tuple containing: 167 | - The name of the relationship to be used when serializing 168 | - The key in the data the relationship is found under 169 | - The relationship resource's JSONAPI.View module 170 | - A boolean for whether the relationship is included by default or not 171 | """ 172 | @spec extrapolate_relationship_config(tuple()) :: {atom(), atom(), module(), boolean()} 173 | def extrapolate_relationship_config({rewrite_key, {data_key, view, :include}}) do 174 | {rewrite_key, data_key, view, true} 175 | end 176 | 177 | def extrapolate_relationship_config({data_key, {view, :include}}) do 178 | {data_key, data_key, view, true} 179 | end 180 | 181 | def extrapolate_relationship_config({rewrite_key, {data_key, view}}) do 182 | {rewrite_key, data_key, view, false} 183 | end 184 | 185 | def extrapolate_relationship_config({data_key, view}) do 186 | {data_key, data_key, view, false} 187 | end 188 | 189 | defp include_view(valid_includes, key) when is_list(valid_includes) do 190 | valid_includes 191 | |> Keyword.get(key) 192 | |> generate_view_tuple 193 | end 194 | 195 | defp include_view(view, _key), do: generate_view_tuple(view) 196 | 197 | defp generate_view_tuple({_rewrite_key, view, :include}), do: {view, :include} 198 | defp generate_view_tuple({view, :include}), do: {view, :include} 199 | defp generate_view_tuple({_rewrite_key, view}), do: {view, :include} 200 | defp generate_view_tuple(view) when is_atom(view), do: {view, :include} 201 | 202 | @spec data_loaded?(map() | list()) :: boolean() 203 | def data_loaded?(rel_data) do 204 | assoc_loaded?(rel_data) && (is_map(rel_data) || is_list(rel_data)) 205 | end 206 | 207 | @spec encode_relation(tuple()) :: map() 208 | def encode_relation({rel_view, rel_data, _rel_url, _conn} = info) do 209 | data = %{ 210 | data: encode_rel_data(rel_view, rel_data) 211 | } 212 | 213 | merge_related_links(data, info, remove_links?()) 214 | end 215 | 216 | defp merge_base_links(%{links: links} = doc, data, view, conn) do 217 | view_links = data |> view.links(conn) |> Map.merge(links) 218 | Map.merge(doc, %{links: view_links}) 219 | end 220 | 221 | defp merge_links(doc, data, view, conn, page, false, options) when is_list(data) do 222 | links = 223 | data 224 | |> view.pagination_links(conn, page, options) 225 | |> Map.merge(%{self: view.url_for_pagination(data, conn, page)}) 226 | 227 | doc 228 | |> Map.merge(%{links: links}) 229 | |> merge_base_links(data, view, conn) 230 | end 231 | 232 | defp merge_links(doc, data, view, conn, _page, false, _options) do 233 | doc 234 | |> Map.merge(%{links: %{self: view.url_for(data, conn)}}) 235 | |> merge_base_links(data, view, conn) 236 | end 237 | 238 | defp merge_links(doc, _data, _view, _conn, _page, _remove_links, _options), do: doc 239 | 240 | defp merge_related_links( 241 | encoded_data, 242 | {rel_view, rel_data, rel_url, conn}, 243 | false = _remove_links 244 | ) do 245 | Map.merge(encoded_data, %{links: %{self: rel_url, related: rel_view.url_for(rel_data, conn)}}) 246 | end 247 | 248 | defp merge_related_links(encoded_rel_data, _info, _remove_links), do: encoded_rel_data 249 | 250 | @spec encode_rel_data(module(), map() | list()) :: map() | nil 251 | def encode_rel_data(_view, nil), do: nil 252 | 253 | def encode_rel_data(view, data) when is_list(data) do 254 | Enum.map(data, &encode_rel_data(view, &1)) 255 | end 256 | 257 | def encode_rel_data(view, data) do 258 | %{ 259 | type: view.resource_type(data), 260 | id: view.id(data) 261 | } 262 | end 263 | 264 | # Flatten and unique all the included objects 265 | @spec flatten_included(keyword()) :: keyword() 266 | def flatten_included(included) do 267 | included 268 | |> List.flatten() 269 | |> Enum.reject(&is_nil/1) 270 | |> Enum.uniq() 271 | end 272 | 273 | defp assoc_loaded?(nil), do: false 274 | defp assoc_loaded?(%{__struct__: Ecto.Association.NotLoaded}), do: false 275 | defp assoc_loaded?(_association), do: true 276 | 277 | defp get_includes(view, query_includes, data) do 278 | includes = get_default_includes(view, data) ++ get_query_includes(view, query_includes, data) 279 | Enum.uniq(includes) 280 | end 281 | 282 | defp get_default_includes(view, data) do 283 | rels = view.resource_relationships(data) 284 | 285 | Enum.filter(rels, &include_rel_by_default/1) 286 | end 287 | 288 | defp include_rel_by_default(rel_config) do 289 | {_rel_key, _data_key, _view, include_by_default} = extrapolate_relationship_config(rel_config) 290 | 291 | include_by_default 292 | end 293 | 294 | defp get_query_includes(view, query_includes, data) do 295 | rels = view.resource_relationships(data) 296 | 297 | query_includes 298 | |> Enum.map(fn 299 | {include, _} -> Keyword.take(rels, [include]) 300 | include -> Keyword.take(rels, [include]) 301 | end) 302 | |> List.flatten() 303 | end 304 | 305 | defp remove_links?, do: Application.get_env(:jsonapi, :remove_links, false) 306 | 307 | defp transform_fields(fields) do 308 | case Utils.String.field_transformation() do 309 | :camelize -> Utils.String.expand_fields(fields, &Utils.String.camelize/1) 310 | :dasherize -> Utils.String.expand_fields(fields, &Utils.String.dasherize/1) 311 | :camelize_shallow -> Utils.String.expand_root_keys(fields, &Utils.String.camelize/1) 312 | :dasherize_shallow -> Utils.String.expand_root_keys(fields, &Utils.String.dasherize/1) 313 | _ -> fields 314 | end 315 | end 316 | end 317 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JSONAPI Elixir 2 | 3 | [![Build](https://github.com/beam-community/jsonapi/actions/workflows/ci.yml/badge.svg)](https://github.com/beam-community/jsonapi/actions/workflows/ci.yml) 4 | [![Hex.pm version](https://img.shields.io/hexpm/v/jsonapi.svg)](https://hex.pm/packages/jsonapi) 5 | [![Hex.pm downloads](https://img.shields.io/hexpm/dt/jsonapi.svg)](https://hex.pm/packages/jsonapi) 6 | [![Hex.pm weekly downloads](https://img.shields.io/hexpm/dw/jsonapi.svg)](https://hex.pm/packages/jsonapi) 7 | [![Hex.pm daily downloads](https://img.shields.io/hexpm/dd/jsonapi.svg)](https://hex.pm/packages/jsonapi) 8 | 9 | A project that will render your data models into [JSONAPI Documents](http://jsonapi.org/format) and parse/verify JSONAPI query strings. 10 | 11 | ## JSONAPI Support 12 | 13 | This library implements [version 1.1](https://jsonapi.org/format/1.1/) 14 | of the JSON:API spec. 15 | 16 | - [x] Basic [JSONAPI Document](http://jsonapi.org/format/#document-top-level) encoding 17 | - [x] Basic support for [compound documents](http://jsonapi.org/format/#document-compound-documents) 18 | - [x] [Links](http://jsonapi.org/format/#document-links) 19 | - [x] Relationship links 20 | - [x] Parsing of `sort` query parameter into Ecto Query order_by 21 | - [x] Parsing and limiting of `filter` keywords. 22 | - [x] Handling of [sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets) 23 | - [x] Handling of [includes](https://jsonapi.org/format/#fetching-includes) 24 | - [x] Handling of [pagination](https://jsonapi.org/format/#fetching-pagination) 25 | - [x] Handling of top level meta data 26 | 27 | ## Documentation 28 | 29 | - [Full docs here](https://hexdocs.pm/jsonapi) 30 | - [JSON API Spec (v1.1)](https://jsonapi.org/format/1.1/) 31 | 32 | ## Badges 33 | 34 | ![](https://github.com/jeregrine/jsonapi/workflows/Continuous%20Integration/badge.svg) 35 | 36 | ## How to use with Phoenix 37 | 38 | ### Installation 39 | 40 | Add the following line to your `mix.deps` file with the desired version to install `jsonapi`. 41 | 42 | ```elixir 43 | defp deps do [ 44 | ... 45 | {:jsonapi, "~> x.x.x"} 46 | ... 47 | ] 48 | ``` 49 | 50 | ### Usage 51 | 52 | Simply add `use JSONAPI.View` either to the top of your view, or to the web.ex view section and add the 53 | proper functions to your view like so. 54 | 55 | ```elixir 56 | defmodule MyApp.PostView do 57 | use JSONAPI.View, type: "posts" 58 | 59 | def fields do 60 | [:text, :body, :excerpt] 61 | end 62 | 63 | def excerpt(post, _conn) do 64 | String.slice(post.body, 0..5) 65 | end 66 | 67 | def meta(data, _conn) do 68 | # this will add meta to each record 69 | # To add meta as a top level property, pass as argument to render function (shown below) 70 | %{meta_text: "meta_#{data[:text]}"} 71 | end 72 | 73 | def relationships do 74 | # The post's author will be included by default 75 | [author: {MyApp.UserView, :include}, 76 | comments: MyApp.CommentView] 77 | end 78 | end 79 | ``` 80 | 81 | You can now call `render(conn, MyApp.PostView, "show.json", %{data: my_data, meta: meta})` 82 | or `"index.json"` normally. 83 | 84 | If you'd like to use this without Phoenix simply use the `JSONAPI.View` and call 85 | `JSONAPI.Serializer.serialize(MyApp.PostView, data, conn, meta)`. 86 | 87 | ## Renaming relationships 88 | If a relationship has a different name in the backend than you would like it to in your API, 89 | you can rewrite its name in the `JSONAPI.View`. You pair the view with the name of the relationship 90 | used in the data (e.g. Ecto schema) to achieve this. Note that you can use a triple instead 91 | of a pair to add the instruction to always include the relation if desired. 92 | 93 | ```elixir 94 | defmodule MyApp.PostView do 95 | use JSONAPI.View, type: "posts" 96 | 97 | def relationships do 98 | # The `author` will be exposed as `creator` and the `comments` will be 99 | # exposed as `critiques` (for some reason). 100 | [creator: {:author, MyApp.UserView, :include}, 101 | critiques: {:comments, MyApp.CommentView}] 102 | end 103 | end 104 | ``` 105 | 106 | ## Parsing and validating a JSONAPI Request 107 | 108 | In your controller you may add 109 | 110 | ```elixir 111 | plug JSONAPI.QueryParser, 112 | filter: ~w(name), 113 | sort: ~w(name title inserted_at), 114 | view: PostView 115 | ``` 116 | 117 | This will add a `JSONAPI.Config` struct called `jsonapi_query` to your 118 | `conn.assigns`. If a user tries to sort, filter, include, or requests an 119 | invalid fieldset it will raise a `Plug` error that shows the proper error 120 | message. 121 | 122 | The config holds the values parsed into things that are easy to pass into an Ecto 123 | query, for example `sort=-name` will be parsed into `sort: [desc: :name]` which 124 | can be passed directly to the `order_by` in Ecto. 125 | 126 | This sort of behavior is consistent for includes. 127 | 128 | The `JSONAPI.QueryParser` plug also supports [sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets). 129 | Please see its documentation for details. 130 | 131 | ## Camelized or Dasherized Fields 132 | 133 | JSONAPI has recommended in the past the use of dashes (`-`) in place of underscore (`_`) as a 134 | word separator for document member keys. However, as of [JSON API Spec (v1.1)](https://jsonapi.org/format/1.1/), it is now recommended that member names 135 | are camelCased. This library provides various configuration options for maximum flexibility including serializing outgoing parameters and deserializing incoming parameters. 136 | 137 | Transforming fields requires two steps: 138 | 139 | 1. camelCase _outgoing_ fields requires you to set the `:field_transformation` 140 | configuration option. Example: 141 | 142 | ```elixir 143 | config :jsonapi, 144 | field_transformation: :camelize # or :dasherize, :camelize_shallow, or :dasherize_shallow 145 | ``` 146 | 147 | 2. Underscoring _incoming_ params (both query and body) requires you add the 148 | `JSONAPI.UnderscoreParameters` Plug to your API's pipeline. This makes it easy to 149 | work with changeset data. 150 | 151 | ```elixir 152 | pipeline :api do 153 | plug JSONAPI.EnsureSpec 154 | plug JSONAPI.UnderscoreParameters 155 | end 156 | ``` 157 | 158 | 3. JSONAPI.Deserializer is a plug designed to make a JSON:API resource object more convenient 159 | to work with when creating or updating resources. This plug works by taking the resource 160 | object format and flattening it into an easier to manipulate Map. 161 | 162 | Note that the deserializer expects the same casing for your _outgoing_ params as your 163 | _incoming_ params. 164 | 165 | Your pipeline in a Phoenix app might look something like this: 166 | 167 | ```elixir 168 | pipeline :api do 169 | plug JSONAPI.EnsureSpec 170 | plug JSONAPI.Deserializer 171 | plug JSONAPI.UnderscoreParameters 172 | end 173 | ``` 174 | 175 | ## Spec Enforcement 176 | 177 | We include a set of Plugs to make enforcing the JSONAPI spec for requests easy. To add spec enforcement to your application, add `JSONAPI.EnsureSpec` to your pipeline: 178 | 179 | ```elixir 180 | plug JSONAPI.EnsureSpec 181 | ``` 182 | 183 | Under-the-hood `JSONAPI.EnsureSpec` relies on four individual plugs: 184 | 185 | - `JSONAPI.ContentTypeNegotiation` — Requires the `Content-Type` and `Accept` headers are set correctly. 186 | 187 | - `JSONAPI.FormatRequired` — Verifies that the JSON body matches the expected `%{data: %{attributes: attributes}}` format. 188 | 189 | - `JSONAPI.IdRequired` — Confirm the `id` key is present in `%{data: data}` and that it matches the resource's `id` in the URI. 190 | 191 | - `JSONAPI.ResponseContentType` — Ensures that you return the correct `Content-Type` header. 192 | 193 | ## Configuration 194 | 195 | ```elixir 196 | config :jsonapi, 197 | host: "www.someotherhost.com", 198 | scheme: "https", 199 | namespace: "/api", 200 | field_transformation: :underscore, 201 | remove_links: false, 202 | json_library: Jason, 203 | paginator: nil 204 | ``` 205 | 206 | - **host**, **scheme**. By default these are pulled from the `conn`, but may be 207 | overridden. 208 | - **namespace**. This optional setting can be used to configure the namespace 209 | your resources live at (e.g. given "http://example.com/api/cars", `"/api"` 210 | would be the namespace). See also `JSONAPI.View` for setting on the resource's 211 | View itself. 212 | - **field_transformation**. This option describes how your API's fields word 213 | boundaries are marked. [JSON API Spec (v1.1)](https://jsonapi.org/format/1.1/) recommends using camelCase (e.g. 214 | `"favoriteColor": blue`). If your API uses camelCase fields, set this value to 215 | `:camelize`. JSON:API v1.0 recommended using a dash (e.g. 216 | `"favorite-color": blue`). If your API uses dashed fields, set this value to 217 | `:dasherize`. If your API uses underscores (e.g. `"favorite_color": "red"`) 218 | set to `:underscore`. To transform only the top-level field keys, use 219 | `:camelize_shallow` or `:dasherize_shallow`. 220 | - **remove_links**. `links` data can optionally be removed from the payload via 221 | setting the configuration above to `true`. Defaults to `false`. 222 | - **json_library**. Defaults to [Jason](https://hex.pm/packages/jason). 223 | - **paginator**. Module implementing pagination links generation. Defaults to `nil`. 224 | 225 | ## Pagination 226 | 227 | Pagination links can be generated by overriding the `JSONAPI.View.pagination_links/4` callback of your view and returning a map containing the links. 228 | 229 | ```elixir 230 | ... 231 | 232 | def pagination_links(data, conn, page, options) do 233 | %{first: nil, last: nil, prev: nil, next: nil} 234 | end 235 | ... 236 | ``` 237 | 238 | Alternatively you can define generic pagination strategies by implementing a module 239 | conforming to the `JSONAPI.Paginator` behavior 240 | 241 | ```elixir 242 | defmodule PageBasedPaginator do 243 | @moduledoc """ 244 | Page based pagination strategy 245 | """ 246 | 247 | @behaviour JSONAPI.Paginator 248 | 249 | @impl true 250 | def paginate(data, view, conn, page, options) do 251 | number = 252 | page 253 | |> Map.get("page", "0") 254 | |> String.to_integer() 255 | 256 | size = 257 | page 258 | |> Map.get("size", "0") 259 | |> String.to_integer() 260 | 261 | total_pages = Keyword.get(options, :total_pages, 0) 262 | 263 | %{ 264 | first: view.url_for_pagination(data, conn, Map.put(page, "page", "1")), 265 | last: view.url_for_pagination(data, conn, Map.put(page, "page", total_pages)), 266 | next: next_link(data, view, conn, number, size, total_pages), 267 | prev: previous_link(data, view, conn, number, size) 268 | } 269 | end 270 | 271 | defp next_link(data, view, conn, page, size, total_pages) 272 | when page < total_pages, 273 | do: view.url_for_pagination(data, conn, %{size: size, page: page + 1}) 274 | 275 | defp next_link(_data, _view, _conn, _page, _size, _total_pages), 276 | do: nil 277 | 278 | defp previous_link(data, view, conn, page, size) 279 | when page > 1, 280 | do: view.url_for_pagination(data, conn, %{size: size, page: page - 1}) 281 | 282 | defp previous_link(_data, _view, _conn, _page, _size), 283 | do: nil 284 | end 285 | ``` 286 | 287 | and configuring it as the global pagination logic in your `mix.config` 288 | 289 | ```elixir 290 | config :jsonapi, :paginator, PageBasedPaginator 291 | ``` 292 | 293 | or as the view pagination logic when using `JSONAPI.View` 294 | 295 | ```elixir 296 | use JSONAPI.View, paginator: PageBasedPaginator 297 | ``` 298 | 299 | Links can be generated using the `JSONAPI.Config.page` information stored in the connection assign `jsonapi_query` and by passing additional information to the `pagination_links/4` callback or your paginator module by passing `options` from your controller. 300 | 301 | Actual pagination is expected to be handled in your application logic and is outside the scope of this library. 302 | 303 | ## Other 304 | 305 | - Feel free to make PR's. I will do my best to respond within a day or two. 306 | - If you want to take one of the TODO items just create an issue or PR and let me know so we avoid duplication. 307 | - If you need help, I am on irc and twitter. 308 | - [Example project](https://github.com/alexjp/jsonapi-testing) 309 | -------------------------------------------------------------------------------- /lib/jsonapi/plugs/query_parser.ex: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.QueryParser do 2 | @moduledoc """ 3 | Implements a fully JSONAPI V1 spec for parsing a complex query string via the 4 | `query_params` field from a `Plug.Conn` struct and returning Elixir datastructures. 5 | The purpose is to validate and encode incoming queries and fail quickly. 6 | 7 | Primarialy this handles: 8 | * [sorts](http://jsonapi.org/format/#fetching-sorting) 9 | * [include](http://jsonapi.org/format/#fetching-includes) 10 | * [filtering](http://jsonapi.org/format/#fetching-filtering) 11 | * [sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets) 12 | * [pagination](http://jsonapi.org/format/#fetching-pagination) 13 | 14 | This Plug works in conjunction with a `JSONAPI.View` as well as some Plug 15 | defined configuration. 16 | 17 | In your controller you may add: 18 | 19 | ``` 20 | plug JSONAPI.QueryParser, 21 | filter: ~w(title), 22 | sort: ~w(created_at title), 23 | include: ~w(others) # optionally specify a list of allowed includes. 24 | view: MyView 25 | ``` 26 | 27 | If you specify which includes are allowed, any include name not in the list 28 | will produce an error. If you omit the `include` list then all relationships 29 | specified by the given resource will be allowed. 30 | 31 | If your controller's index function receives a query with params inside those 32 | bounds it will build a `JSONAPI.Config` that has all the validated and parsed 33 | fields for your usage. The final configuration will be added to assigns 34 | `jsonapi_query`. 35 | 36 | The final output will be a `JSONAPI.Config` struct and will look similar to the 37 | following: 38 | 39 | %JSONAPI.Config{ 40 | view: MyView, 41 | opts: [view: MyView, sort: ["created_at", "title"], filter: ["title"]], 42 | sort: [desc: :created_at] # Easily insertable into an ecto order_by, 43 | filter: [title: "my title"] # Easily reduceable into ecto where clauses 44 | include: [comments: :user] # Easily insertable into a Repo.preload, 45 | fields: %{"myview" => [:id, :text], "comment" => [:id, :body], 46 | page: %{ 47 | limit: limit, 48 | offset: offset, 49 | page: page, 50 | size: size, 51 | cursor: cursor 52 | }} 53 | } 54 | 55 | The final result should allow you to build a query quickly and with little overhead. 56 | 57 | ## Sparse Fieldsets 58 | 59 | Sparse fieldsets are supported. By default your response will include all 60 | available fields. Note that the query to your database is left to you. Should 61 | you want to query your DB for specific fields `JSONAPI.Config.fields` will 62 | return the requested fields for each resource (see above example). 63 | 64 | ## Options 65 | * `:view` - The JSONAPI View which is the basis for this plug. 66 | * `:sort` - List of atoms which define which fields can be sorted on. 67 | * `:filter` - List of atoms which define which fields can be filtered on. 68 | 69 | ## Dasherized Fields 70 | 71 | Note that if your API is returning dasherized fields (e.g. `"dog-breed": "Corgi"`) 72 | we recommend that you include the `JSONAPI.UnderscoreParameters` Plug in your 73 | API's pipeline with the `replace_query_params` option set to `true`. This will 74 | underscore fields for easier operations in your code. 75 | 76 | For more details please see `JSONAPI.UnderscoreParameters`. 77 | """ 78 | @behaviour Plug 79 | 80 | import JSONAPI.Utils.IncludeTree 81 | import JSONAPI.Utils.String, only: [underscore: 1] 82 | 83 | alias JSONAPI.{Config, Deprecation} 84 | alias JSONAPI.Exceptions.InvalidQuery 85 | alias Plug.Conn 86 | 87 | @impl Plug 88 | def init(opts) do 89 | build_config(opts) 90 | end 91 | 92 | @impl Plug 93 | def call(conn, opts) do 94 | query_params_config_struct = 95 | conn 96 | |> Conn.fetch_query_params() 97 | |> Map.get(:query_params) 98 | |> struct_from_map(%Config{}) 99 | 100 | config = 101 | opts 102 | |> parse_fields(query_params_config_struct.fields) 103 | |> parse_include(query_params_config_struct.include) 104 | |> parse_filter(query_params_config_struct.filter) 105 | |> parse_sort(query_params_config_struct.sort) 106 | |> parse_pagination(query_params_config_struct.page) 107 | 108 | Conn.assign(conn, :jsonapi_query, config) 109 | end 110 | 111 | def parse_pagination(config, map) when map_size(map) == 0, do: config 112 | 113 | def parse_pagination(%Config{} = config, page), do: Map.put(config, :page, page) 114 | 115 | @spec parse_filter(Config.t(), keyword()) :: Config.t() 116 | def parse_filter(config, map) when map_size(map) == 0, do: config 117 | 118 | def parse_filter(%Config{opts: opts} = config, filter) do 119 | opts_filter = Keyword.get(opts, :filter, []) 120 | 121 | Enum.reduce(filter, config, fn {key, val}, acc -> 122 | check_filter_allowed!(opts_filter, key, config) 123 | 124 | keys = key |> String.split(".") |> Enum.map(&String.to_existing_atom/1) 125 | filter = deep_merge(acc.filter, put_as_tree([], keys, val)) 126 | %{acc | filter: filter} 127 | end) 128 | end 129 | 130 | defp check_filter_allowed!(filters, key, config) do 131 | unless key in filters do 132 | raise InvalidQuery, resource: config.view.type(), param: key, param_type: :filter 133 | end 134 | end 135 | 136 | @spec parse_fields(Config.t(), map()) :: Config.t() | no_return() 137 | def parse_fields(%Config{} = config, fields) when fields == %{}, do: config 138 | 139 | def parse_fields(%Config{} = config, fields) do 140 | Enum.reduce(fields, config, fn {type, value}, acc -> 141 | valid_fields = 142 | config 143 | |> get_valid_fields_for_type(type) 144 | |> Enum.into(MapSet.new()) 145 | 146 | requested_fields = 147 | try do 148 | value 149 | |> String.split(",") 150 | |> Enum.filter(&(&1 !== "")) 151 | |> Enum.map(&underscore/1) 152 | |> Enum.into(MapSet.new(), &String.to_existing_atom/1) 153 | rescue 154 | ArgumentError -> raise_invalid_field_names(value, config.view.type()) 155 | end 156 | 157 | size = MapSet.size(requested_fields) 158 | 159 | case MapSet.subset?(requested_fields, valid_fields) do 160 | # no fields if empty - https://jsonapi.org/format/#fetching-sparse-fieldsets 161 | false when size > 0 -> 162 | bad_fields = 163 | requested_fields 164 | |> MapSet.difference(valid_fields) 165 | |> MapSet.to_list() 166 | |> Enum.join(",") 167 | 168 | raise_invalid_field_names(bad_fields, config.view.type()) 169 | 170 | _ -> 171 | %{acc | fields: Map.put(acc.fields, type, MapSet.to_list(requested_fields))} 172 | end 173 | end) 174 | end 175 | 176 | def parse_sort(config, nil), do: config 177 | 178 | def parse_sort(%Config{opts: opts} = config, sort_fields) do 179 | sorts = 180 | sort_fields 181 | |> String.split(",") 182 | |> Enum.map(fn field -> 183 | valid_sort = Keyword.get(opts, :sort, []) 184 | [_, direction, field] = Regex.run(~r/(-?)(\S*)/, field) 185 | 186 | unless field in valid_sort do 187 | raise InvalidQuery, resource: config.view.type(), param: field, param_type: :sort 188 | end 189 | 190 | build_sort(direction, String.to_existing_atom(field)) 191 | end) 192 | |> List.flatten() 193 | 194 | %{config | sort: sorts} 195 | end 196 | 197 | def build_sort("", field), do: [asc: field] 198 | def build_sort("-", field), do: [desc: field] 199 | 200 | def parse_include(config, []), do: config 201 | 202 | def parse_include(%Config{} = config, include_str) do 203 | includes = handle_include(include_str, config) 204 | 205 | Map.put(config, :include, includes) 206 | end 207 | 208 | def handle_include(str, config) when is_binary(str) do 209 | valid_includes = get_base_relationships(config.view) 210 | 211 | includes = 212 | str 213 | |> String.split(",") 214 | |> Enum.filter(&(&1 !== "")) 215 | |> Enum.map(&underscore/1) 216 | 217 | Enum.reduce(includes, [], &include_reducer(config, valid_includes, &1, &2)) 218 | end 219 | 220 | defp include_reducer(config, valid_includes, inc, acc) do 221 | # if an explicit list of allowed includes was specified, check this include 222 | # against it: 223 | check_include_allowed!(inc, config) 224 | 225 | if inc =~ ~r/\w+\.\w+/ do 226 | acc ++ handle_nested_include(inc, valid_includes, config) 227 | else 228 | inc = 229 | try do 230 | String.to_existing_atom(inc) 231 | rescue 232 | ArgumentError -> raise_invalid_include_query(inc, config.view.type()) 233 | end 234 | 235 | # credo:disable-for-next-line 236 | if Enum.any?(valid_includes, fn {key, _val} -> key == inc end) do 237 | Enum.reverse([inc | acc]) 238 | else 239 | raise_invalid_include_query(inc, config.view.type()) 240 | end 241 | end 242 | end 243 | 244 | defp check_include_allowed!(key, %Config{opts: opts, view: view}) do 245 | if opts do 246 | check_include_allowed!(key, Keyword.get(opts, :include), view) 247 | end 248 | end 249 | 250 | defp check_include_allowed!(key, allowed_includes, view) when is_list(allowed_includes) do 251 | unless key in allowed_includes do 252 | raise_invalid_include_query(key, view.type()) 253 | end 254 | end 255 | 256 | defp check_include_allowed!(_key, nil, _view) do 257 | # all includes are allowed if none are specified in input config 258 | end 259 | 260 | @spec handle_nested_include(key :: String.t(), valid_include :: list(), config :: Config.t()) :: 261 | list() | no_return() 262 | def handle_nested_include(key, valid_includes, config) do 263 | keys = 264 | try do 265 | key 266 | |> String.split(".") 267 | |> Enum.map(&String.to_existing_atom/1) 268 | rescue 269 | ArgumentError -> raise_invalid_include_query(key, config.view.type()) 270 | end 271 | 272 | last = List.last(keys) 273 | path = Enum.slice(keys, 0, Enum.count(keys) - 1) 274 | 275 | if member_of_tree?(keys, valid_includes) do 276 | put_as_tree([], path, last) 277 | else 278 | raise_invalid_include_query(key, config.view.type()) 279 | end 280 | end 281 | 282 | @spec get_valid_fields_for_type(Config.t(), String.t()) :: list(atom()) 283 | def get_valid_fields_for_type(%Config{view: view}, type) do 284 | if type == view.type() do 285 | view.fields() 286 | else 287 | get_view_for_type(view, type).fields() 288 | end 289 | end 290 | 291 | @spec get_view_for_type(module(), String.t()) :: module() | no_return() 292 | def get_view_for_type(view, type) do 293 | case Enum.find(view.relationships(), fn relationship -> 294 | field_valid_for_relationship?(relationship, type) 295 | end) do 296 | {_, view} -> view 297 | nil -> raise_invalid_field_names(type, view.type()) 298 | end 299 | end 300 | 301 | @spec field_valid_for_relationship?({atom(), module()}, String.t()) :: boolean() 302 | defp field_valid_for_relationship?({key, view}, expected_type) do 303 | cond do 304 | view.type() == expected_type -> 305 | true 306 | 307 | Atom.to_string(key) == expected_type -> 308 | Deprecation.warn(:query_parser_fields) 309 | true 310 | 311 | true -> 312 | false 313 | end 314 | end 315 | 316 | @spec raise_invalid_include_query(param :: String.t(), resource_type :: String.t()) :: 317 | no_return() 318 | defp raise_invalid_include_query(param, resource_type) do 319 | raise InvalidQuery, resource: resource_type, param: param, param_type: :include 320 | end 321 | 322 | @spec raise_invalid_field_names(bad_fields :: String.t(), resource_type :: String.t()) :: 323 | no_return() 324 | defp raise_invalid_field_names(bad_fields, resource_type) do 325 | raise InvalidQuery, resource: resource_type, param: bad_fields, param_type: :fields 326 | end 327 | 328 | defp build_config(opts) do 329 | view = Keyword.fetch!(opts, :view) 330 | struct(Config, opts: opts, view: view) 331 | end 332 | 333 | defp struct_from_map(params, struct) do 334 | processed_map = 335 | for {struct_key, _} <- Map.from_struct(struct), into: %{} do 336 | case Map.get(params, to_string(struct_key)) do 337 | nil -> {false, false} 338 | value -> {struct_key, value} 339 | end 340 | end 341 | 342 | struct(struct, processed_map) 343 | end 344 | end 345 | -------------------------------------------------------------------------------- /test/jsonapi/view_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.ViewTest do 2 | use ExUnit.Case 3 | 4 | defmodule PostView do 5 | use JSONAPI.View, type: "posts", namespace: "/api" 6 | 7 | def fields do 8 | [:title, :body] 9 | end 10 | 11 | def hidden(%{title: "Hidden body"}) do 12 | [:body] 13 | end 14 | 15 | def hidden(_), do: [] 16 | end 17 | 18 | defmodule CommentView do 19 | use JSONAPI.View, type: "comments", namespace: "/api" 20 | 21 | def fields do 22 | [:body] 23 | end 24 | end 25 | 26 | defmodule UserView do 27 | use JSONAPI.View, type: "users" 28 | 29 | def fields do 30 | [:age, :first_name, :last_name, :full_name, :password] 31 | end 32 | 33 | def full_name(user, _conn) do 34 | "#{user.first_name} #{user.last_name}" 35 | end 36 | 37 | def hidden(_data) do 38 | [:password] 39 | end 40 | end 41 | 42 | defmodule CarView do 43 | use JSONAPI.View, type: "cars", namespace: "" 44 | end 45 | 46 | defmodule DynamicView do 47 | use JSONAPI.View 48 | 49 | def type, do: "dyns" 50 | 51 | def fields, do: [:static_fun, :static_field, :dynamic_1, :dynamic_2] 52 | 53 | def static_fun(_data, _conn), do: "static_fun/2" 54 | 55 | def get_field(field, _data, _conn), do: "#{field}!" 56 | end 57 | 58 | defmodule PolymorphicDataOne do 59 | defstruct [:some_field] 60 | end 61 | 62 | defmodule PolymorphicDataTwo do 63 | defstruct [:some_other_field] 64 | end 65 | 66 | defmodule PolymorphicView do 67 | use JSONAPI.View, polymorphic_resource?: true 68 | 69 | def polymorphic_type(data) do 70 | case data do 71 | %PolymorphicDataOne{} -> 72 | "polymorphic_data_one" 73 | 74 | %PolymorphicDataTwo{} -> 75 | "polymorphic_data_one" 76 | end 77 | end 78 | 79 | def polymorphic_fields(data) do 80 | case data do 81 | %PolymorphicDataOne{} -> 82 | [:some_field] 83 | 84 | %PolymorphicDataTwo{} -> 85 | [:some_other_field] 86 | end 87 | end 88 | end 89 | 90 | setup do 91 | Application.put_env(:jsonapi, :field_transformation, :underscore) 92 | Application.put_env(:jsonapi, :namespace, "/other-api") 93 | 94 | on_exit(fn -> 95 | Application.delete_env(:jsonapi, :field_transformation) 96 | Application.delete_env(:jsonapi, :namespace) 97 | end) 98 | 99 | {:ok, []} 100 | end 101 | 102 | test "type/0 when specified via using macro" do 103 | assert PostView.type() == "posts" 104 | end 105 | 106 | describe "resource_type/1" do 107 | test "equals result of type/0 if resource is not polymorphic" do 108 | assert PostView.type() == PostView.resource_type(%{}) 109 | end 110 | 111 | test "equals result of polymorphic_type/1 if resource is polymorphic" do 112 | assert PolymorphicView.polymorphic_type(%PolymorphicDataOne{}) == 113 | PolymorphicView.resource_type(%PolymorphicDataOne{}) 114 | 115 | assert PolymorphicView.polymorphic_type(%PolymorphicDataTwo{}) == 116 | PolymorphicView.resource_type(%PolymorphicDataTwo{}) 117 | end 118 | end 119 | 120 | describe "namespace/0" do 121 | setup do 122 | Application.put_env(:jsonapi, :namespace, "/cake") 123 | 124 | on_exit(fn -> 125 | Application.delete_env(:jsonapi, :namespace) 126 | end) 127 | 128 | {:ok, []} 129 | end 130 | 131 | test "uses macro configuration first" do 132 | assert PostView.namespace() == "/api" 133 | end 134 | 135 | test "uses global namespace if available" do 136 | assert UserView.namespace() == "/cake" 137 | end 138 | 139 | test "can be blank" do 140 | assert CarView.namespace() == "" 141 | end 142 | end 143 | 144 | describe "url_for/2 when host and scheme not configured" do 145 | test "url_for/2" do 146 | assert PostView.url_for(nil, nil) == "/api/posts" 147 | assert PostView.url_for([], nil) == "/api/posts" 148 | assert PostView.url_for(%{id: 1}, nil) == "/api/posts/1" 149 | assert PostView.url_for([], %Plug.Conn{}) == "http://www.example.com/api/posts" 150 | assert PostView.url_for([], %Plug.Conn{port: 123}) == "http://www.example.com:123/api/posts" 151 | assert PostView.url_for(%{id: 1}, %Plug.Conn{}) == "http://www.example.com/api/posts/1" 152 | 153 | assert PostView.url_for_rel([], "comments", %Plug.Conn{}) == 154 | "http://www.example.com/api/posts/relationships/comments" 155 | 156 | assert PostView.url_for_rel(%{id: 1}, "comments", %Plug.Conn{}) == 157 | "http://www.example.com/api/posts/1/relationships/comments" 158 | end 159 | end 160 | 161 | describe "url_for/2 when host configured" do 162 | setup do 163 | Application.put_env(:jsonapi, :host, "www.otherhost.com") 164 | 165 | on_exit(fn -> 166 | Application.delete_env(:jsonapi, :host) 167 | end) 168 | 169 | {:ok, []} 170 | end 171 | 172 | test "uses configured host instead of that on Conn" do 173 | assert PostView.url_for_rel([], "comments", %Plug.Conn{}) == 174 | "http://www.otherhost.com/api/posts/relationships/comments" 175 | 176 | assert PostView.url_for_rel(%{id: 1}, "comments", %Plug.Conn{}) == 177 | "http://www.otherhost.com/api/posts/1/relationships/comments" 178 | 179 | assert PostView.url_for([], %Plug.Conn{}) == "http://www.otherhost.com/api/posts" 180 | assert PostView.url_for(%{id: 1}, %Plug.Conn{}) == "http://www.otherhost.com/api/posts/1" 181 | end 182 | end 183 | 184 | describe "url_for/2 when scheme configured" do 185 | setup do 186 | Application.put_env(:jsonapi, :scheme, "ftp") 187 | 188 | on_exit(fn -> 189 | Application.delete_env(:jsonapi, :scheme) 190 | end) 191 | 192 | {:ok, []} 193 | end 194 | 195 | test "uses configured scheme instead of that on Conn" do 196 | assert PostView.url_for([], %Plug.Conn{}) == "ftp://www.example.com/api/posts" 197 | assert PostView.url_for(%{id: 1}, %Plug.Conn{}) == "ftp://www.example.com/api/posts/1" 198 | 199 | assert PostView.url_for_rel([], "comments", %Plug.Conn{}) == 200 | "ftp://www.example.com/api/posts/relationships/comments" 201 | 202 | assert PostView.url_for_rel(%{id: 1}, "comments", %Plug.Conn{}) == 203 | "ftp://www.example.com/api/posts/1/relationships/comments" 204 | end 205 | end 206 | 207 | describe "url_for/2 when port configured" do 208 | setup do 209 | Application.put_env(:jsonapi, :port, 42) 210 | 211 | on_exit(fn -> 212 | Application.delete_env(:jsonapi, :port) 213 | end) 214 | 215 | {:ok, []} 216 | end 217 | 218 | test "uses configured port instead of that on Conn" do 219 | assert PostView.url_for([], %Plug.Conn{}) == "http://www.example.com:42/api/posts" 220 | assert PostView.url_for(%{id: 1}, %Plug.Conn{}) == "http://www.example.com:42/api/posts/1" 221 | 222 | assert PostView.url_for_rel([], "comments", %Plug.Conn{}) == 223 | "http://www.example.com:42/api/posts/relationships/comments" 224 | 225 | assert PostView.url_for_rel(%{id: 1}, "comments", %Plug.Conn{}) == 226 | "http://www.example.com:42/api/posts/1/relationships/comments" 227 | end 228 | end 229 | 230 | describe "url_for_pagination/3" do 231 | setup do 232 | {:ok, conn: Plug.Conn.fetch_query_params(%Plug.Conn{})} 233 | end 234 | 235 | test "with pagination information", %{conn: conn} do 236 | assert PostView.url_for_pagination(nil, conn, %{}) == "http://www.example.com/api/posts" 237 | 238 | assert PostView.url_for_pagination(nil, conn, %{number: 1, size: 10}) == 239 | "http://www.example.com/api/posts?page%5Bsize%5D=10&page%5Bnumber%5D=1" 240 | end 241 | 242 | test "with query parameters", %{conn: conn} do 243 | conn_with_query_params = 244 | Kernel.update_in(conn.query_params, &Map.put(&1, "comments", [5, 2])) 245 | 246 | assert PostView.url_for_pagination(nil, conn_with_query_params, %{number: 1, size: 10}) == 247 | "http://www.example.com/api/posts?comments%5B%5D=5&comments%5B%5D=2&page%5Bsize%5D=10&page%5Bnumber%5D=1" 248 | 249 | assert PostView.url_for_pagination(nil, conn_with_query_params, %{}) == 250 | "http://www.example.com/api/posts?comments%5B%5D=5&comments%5B%5D=2" 251 | end 252 | end 253 | 254 | test "render/2 is defined when 'Phoenix' is loaded" do 255 | assert {:render, 2} in CommentView.__info__(:functions) 256 | end 257 | 258 | test "show renders with data, conn" do 259 | data = CommentView.render("show.json", %{data: %{id: 1, body: "hi"}, conn: %Plug.Conn{}}) 260 | assert data.data.attributes.body == "hi" 261 | end 262 | 263 | test "show renders with data, conn, meta" do 264 | data = 265 | CommentView.render("show.json", %{ 266 | data: %{id: 1, body: "hi"}, 267 | conn: %Plug.Conn{}, 268 | meta: %{total_pages: 100} 269 | }) 270 | 271 | assert data.meta.total_pages == 100 272 | end 273 | 274 | test "index renders with data, conn" do 275 | data = 276 | CommentView.render("index.json", %{ 277 | data: [%{id: 1, body: "hi"}], 278 | conn: Plug.Conn.fetch_query_params(%Plug.Conn{}) 279 | }) 280 | 281 | data = Enum.at(data.data, 0) 282 | assert data.attributes.body == "hi" 283 | end 284 | 285 | test "index renders with data, conn, meta" do 286 | data = 287 | CommentView.render("index.json", %{ 288 | data: [%{id: 1, body: "hi"}], 289 | conn: Plug.Conn.fetch_query_params(%Plug.Conn{}), 290 | meta: %{total_pages: 100} 291 | }) 292 | 293 | assert data.meta.total_pages == 100 294 | end 295 | 296 | test "visible_fields/2 returns all field names by default" do 297 | data = %{age: 100, first_name: "Jason", last_name: "S", password: "securepw"} 298 | 299 | assert [:age, :first_name, :last_name, :full_name] == 300 | UserView.visible_fields(data, %Plug.Conn{}) 301 | end 302 | 303 | test "visible_fields/2 removes any hidden field names" do 304 | data = %{title: "Hidden body", body: "Something"} 305 | 306 | assert [:title] == PostView.visible_fields(data, %Plug.Conn{}) 307 | end 308 | 309 | test "visible_fields/2 trims returned field names to only those requested" do 310 | data = %{body: "Chunky", title: "Bacon"} 311 | config = %JSONAPI.Config{fields: %{PostView.type() => [:body]}} 312 | conn = %Plug.Conn{assigns: %{jsonapi_query: config}} 313 | 314 | assert [:body] == PostView.visible_fields(data, conn) 315 | end 316 | 317 | test "attributes/2 does not display hidden fields" do 318 | expected_map = %{age: 100, first_name: "Jason", last_name: "S", full_name: "Jason S"} 319 | 320 | assert expected_map == 321 | UserView.attributes( 322 | %{age: 100, first_name: "Jason", last_name: "S", password: "securepw"}, 323 | nil 324 | ) 325 | end 326 | 327 | test "attributes/2 does not display hidden fields based on a condition" do 328 | hidden_expected_map = %{title: "Hidden body"} 329 | normal_expected_map = %{title: "Other title", body: "Something"} 330 | 331 | assert hidden_expected_map == 332 | PostView.attributes( 333 | %{title: "Hidden body", body: "Something"}, 334 | nil 335 | ) 336 | 337 | assert normal_expected_map == 338 | PostView.attributes( 339 | %{title: "Other title", body: "Something"}, 340 | nil 341 | ) 342 | end 343 | 344 | test "attributes/2 can return only requested fields" do 345 | data = %{body: "Chunky", title: "Bacon"} 346 | config = %JSONAPI.Config{fields: %{PostView.type() => [:body]}} 347 | conn = %Plug.Conn{assigns: %{jsonapi_query: config}} 348 | 349 | assert %{body: "Chunky"} == PostView.attributes(data, conn) 350 | end 351 | 352 | test "attributes/2 can return dynamic fields" do 353 | data = %{static_field: "static_field from the map"} 354 | conn = %Plug.Conn{assigns: %{jsonapi_query: %JSONAPI.Config{}}} 355 | 356 | assert %{ 357 | dynamic_1: "dynamic_1!", 358 | dynamic_2: "dynamic_2!", 359 | static_field: "static_field!", 360 | static_fun: "static_fun/2" 361 | } == DynamicView.attributes(data, conn) 362 | end 363 | 364 | test "attributes/2 can return polymorphic fields" do 365 | data = %PolymorphicDataTwo{some_other_field: "foo"} 366 | conn = %Plug.Conn{assigns: %{jsonapi_query: %JSONAPI.Config{}}} 367 | 368 | assert %{ 369 | some_other_field: "foo" 370 | } == PolymorphicView.attributes(data, conn) 371 | end 372 | end 373 | -------------------------------------------------------------------------------- /test/jsonapi_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPITest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | @default_data %{ 6 | id: 1, 7 | text: "Hello", 8 | body: "Hi", 9 | author: %{username: "jason", id: 2}, 10 | other_user: %{username: "josh", id: 3} 11 | } 12 | 13 | defmodule PostView do 14 | use JSONAPI.View 15 | 16 | def fields, do: [:text, :body, :excerpt, :first_character] 17 | def type, do: "mytype" 18 | 19 | def relationships do 20 | [author: {JSONAPITest.UserView, :include}, other_user: JSONAPITest.UserView] 21 | end 22 | 23 | def excerpt(post, _conn) do 24 | String.slice(post.text, 0..1) 25 | end 26 | 27 | def first_character(post, _conn) do 28 | String.first(post.text) 29 | end 30 | end 31 | 32 | defmodule UserView do 33 | use JSONAPI.View 34 | 35 | def fields, do: [:username] 36 | def type, do: "user" 37 | 38 | def links(user, _conn) do 39 | %{ 40 | profile: %{ 41 | href: "#{path()}/#{user.username}", 42 | meta: %{ 43 | method: "get" 44 | } 45 | } 46 | } 47 | end 48 | 49 | def relationships do 50 | [company: JSONAPITest.CompanyView] 51 | end 52 | end 53 | 54 | defmodule CompanyView do 55 | use JSONAPI.View 56 | 57 | def fields, do: [:name] 58 | def type, do: "company" 59 | 60 | def relationships do 61 | [industry: JSONAPITest.IndustryView] 62 | end 63 | end 64 | 65 | defmodule IndustryView do 66 | use JSONAPI.View 67 | 68 | def fields, do: [:name] 69 | def type, do: "industry" 70 | 71 | def relationships do 72 | [tags: JSONAPITest.TagView] 73 | end 74 | end 75 | 76 | defmodule TagView do 77 | use JSONAPI.View 78 | 79 | def fields, do: [:name] 80 | def type, do: "tag" 81 | def relationships, do: [] 82 | end 83 | 84 | defmodule MyPostPlug do 85 | use Plug.Builder 86 | 87 | plug(JSONAPI.QueryParser, 88 | view: JSONAPITest.PostView, 89 | sort: [:text], 90 | filter: [:text] 91 | ) 92 | 93 | plug(:passthrough) 94 | 95 | defp passthrough(conn, _) do 96 | resp = 97 | PostView 98 | |> JSONAPI.Serializer.serialize(conn.assigns[:data], conn, conn.assigns[:meta]) 99 | |> Jason.encode!() 100 | 101 | Plug.Conn.send_resp(conn, 200, resp) 102 | end 103 | end 104 | 105 | setup do 106 | Application.put_env(:jsonapi, :field_transformation, :underscore) 107 | 108 | on_exit(fn -> 109 | Application.delete_env(:jsonapi, :field_transformation) 110 | end) 111 | 112 | {:ok, []} 113 | end 114 | 115 | test "handles simple requests" do 116 | conn = 117 | :get 118 | |> conn("/posts") 119 | |> Plug.Conn.assign(:data, [@default_data]) 120 | |> Plug.Conn.assign(:meta, %{total_pages: 1}) 121 | |> Plug.Conn.fetch_query_params() 122 | |> MyPostPlug.call([]) 123 | 124 | json = Jason.decode!(conn.resp_body) 125 | 126 | assert Map.has_key?(json, "data") 127 | data_list = Map.get(json, "data") 128 | meta = Map.get(json, "meta") 129 | assert meta["total_pages"] == 1 130 | 131 | assert Enum.count(data_list) == 1 132 | [data | _] = data_list 133 | assert Map.get(data["attributes"], "body") == "Hi" 134 | assert Map.get(data["attributes"], "text") == "Hello" 135 | assert Map.get(data["attributes"], "excerpt") == "He" 136 | assert Map.get(data, "type") == "mytype" 137 | assert Map.get(data, "id") == "1" 138 | 139 | relationships = Map.get(data, "relationships") 140 | assert map_size(relationships) == 2 141 | assert relationships |> Map.keys() |> Enum.sort() == ["author", "other_user"] 142 | author_rel = Map.get(relationships, "author") 143 | 144 | assert get_in(author_rel, ["data", "type"]) == "user" 145 | assert get_in(author_rel, ["data", "id"]) == "2" 146 | 147 | assert Map.has_key?(json, "included") 148 | included = Map.get(json, "included") 149 | assert is_list(included) 150 | assert Enum.count(included) == 1 151 | 152 | [author | _] = included 153 | assert Map.get(author, "type") == "user" 154 | assert Map.get(author, "id") == "2" 155 | 156 | assert Map.has_key?(json, "links") 157 | end 158 | 159 | test "handles includes properly" do 160 | conn = 161 | :get 162 | |> conn("/posts?include=other_user") 163 | |> Plug.Conn.assign(:data, [@default_data]) 164 | |> Plug.Conn.fetch_query_params() 165 | |> MyPostPlug.call([]) 166 | 167 | json = Jason.decode!(conn.resp_body) 168 | 169 | assert Map.has_key?(json, "data") 170 | data_list = Map.get(json, "data") 171 | 172 | assert Enum.count(data_list) == 1 173 | [data | _] = data_list 174 | assert Map.get(data, "type") == "mytype" 175 | assert Map.get(data, "id") == "1" 176 | 177 | relationships = Map.get(data, "relationships") 178 | assert map_size(relationships) == 2 179 | assert relationships |> Map.keys() |> Enum.sort() == ["author", "other_user"] 180 | author_rel = Map.get(relationships, "author") 181 | 182 | assert get_in(author_rel, ["data", "type"]) == "user" 183 | assert get_in(author_rel, ["data", "id"]) == "2" 184 | 185 | other_user = Map.get(relationships, "other_user") 186 | 187 | assert get_in(other_user, ["data", "type"]) == "user" 188 | assert get_in(other_user, ["data", "id"]) == "3" 189 | 190 | assert Map.has_key?(json, "included") 191 | included = Map.get(json, "included") 192 | assert is_list(included) 193 | assert Enum.count(included) == 2 194 | 195 | assert Enum.find(included, fn include -> 196 | Map.get(include, "type") == "user" && Map.get(include, "id") == "2" 197 | end) 198 | 199 | assert Enum.find(included, fn include -> 200 | Map.get(include, "type") == "user" && Map.get(include, "id") == "3" 201 | end) 202 | 203 | assert Map.has_key?(json, "links") 204 | end 205 | 206 | test "handles empty includes properly" do 207 | conn = 208 | :get 209 | |> conn("/posts?include=") 210 | |> Plug.Conn.assign(:data, [@default_data]) 211 | |> Plug.Conn.fetch_query_params() 212 | |> MyPostPlug.call([]) 213 | 214 | json = Jason.decode!(conn.resp_body) 215 | 216 | assert Map.has_key?(json, "data") 217 | data_list = Map.get(json, "data") 218 | 219 | assert Enum.count(data_list) == 1 220 | [data | _] = data_list 221 | assert Map.get(data, "type") == "mytype" 222 | assert Map.get(data, "id") == "1" 223 | 224 | relationships = Map.get(data, "relationships") 225 | assert map_size(relationships) == 2 226 | assert relationships |> Map.keys() |> Enum.sort() == ["author", "other_user"] 227 | author_rel = Map.get(relationships, "author") 228 | 229 | assert get_in(author_rel, ["data", "type"]) == "user" 230 | assert get_in(author_rel, ["data", "id"]) == "2" 231 | 232 | other_user = Map.get(relationships, "other_user") 233 | 234 | # not included 235 | assert get_in(other_user, ["data", "type"]) == "user" 236 | assert get_in(other_user, ["data", "id"]) == "3" 237 | 238 | assert Map.has_key?(json, "included") 239 | included = Map.get(json, "included") 240 | assert is_list(included) 241 | # author is atuomatically included 242 | assert Enum.count(included) == 1 243 | end 244 | 245 | test "handles deep nested includes properly" do 246 | data = [ 247 | %{ 248 | id: 1, 249 | text: "Hello", 250 | body: "Hi", 251 | author: %{username: "jason", id: 2}, 252 | other_user: %{ 253 | id: 1, 254 | username: "jim", 255 | first_name: "Jimmy", 256 | last_name: "Beam", 257 | company: %{ 258 | id: 2, 259 | name: "acme", 260 | industry: %{ 261 | id: 4, 262 | name: "stuff", 263 | tags: [ 264 | %{id: 3, name: "a tag"}, 265 | %{id: 4, name: "another tag"} 266 | ] 267 | } 268 | } 269 | } 270 | } 271 | ] 272 | 273 | conn = 274 | :get 275 | |> conn("/posts?include=other_user.company.industry.tags") 276 | |> Plug.Conn.assign(:data, data) 277 | |> Plug.Conn.fetch_query_params() 278 | |> MyPostPlug.call([]) 279 | 280 | json = Jason.decode!(conn.resp_body) 281 | 282 | assert Map.has_key?(json, "data") 283 | data_list = Map.get(json, "data") 284 | 285 | assert Enum.count(data_list) == 1 286 | [data | _] = data_list 287 | assert Map.get(data, "type") == "mytype" 288 | assert Map.get(data, "id") == "1" 289 | 290 | relationships = Map.get(data, "relationships") 291 | assert map_size(relationships) == 2 292 | assert relationships |> Map.keys() |> Enum.sort() == ["author", "other_user"] 293 | author_rel = Map.get(relationships, "author") 294 | 295 | assert get_in(author_rel, ["data", "type"]) == "user" 296 | assert get_in(author_rel, ["data", "id"]) == "2" 297 | 298 | other_user = Map.get(relationships, "other_user") 299 | 300 | assert get_in(other_user, ["data", "type"]) == "user" 301 | assert get_in(other_user, ["data", "id"]) == "1" 302 | 303 | assert Map.has_key?(json, "included") 304 | included = Map.get(json, "included") 305 | assert is_list(included) 306 | assert Enum.count(included) == 6 307 | 308 | assert Enum.find(included, fn include -> 309 | Map.get(include, "type") == "user" && Map.get(include, "id") == "2" 310 | end) 311 | 312 | assert Enum.find(included, fn include -> 313 | Map.get(include, "type") == "user" && Map.get(include, "id") == "1" 314 | end) 315 | 316 | assert Enum.find(included, fn include -> 317 | Map.get(include, "type") == "company" && Map.get(include, "id") == "2" 318 | end) 319 | 320 | assert Enum.find(included, fn include -> 321 | Map.get(include, "type") == "industry" && Map.get(include, "id") == "4" 322 | end) 323 | 324 | assert Enum.find(included, fn include -> 325 | Map.get(include, "type") == "tag" && Map.get(include, "id") == "3" 326 | end) 327 | 328 | assert Enum.find(included, fn include -> 329 | Map.get(include, "type") == "tag" && Map.get(include, "id") == "4" 330 | end) 331 | 332 | assert Map.has_key?(json, "links") 333 | end 334 | 335 | describe "with an underscored API" do 336 | setup do 337 | Application.put_env(:jsonapi, :field_transformation, :underscore) 338 | 339 | on_exit(fn -> 340 | Application.delete_env(:jsonapi, :field_transformation) 341 | end) 342 | 343 | {:ok, []} 344 | end 345 | 346 | test "handles sparse fields properly" do 347 | conn = 348 | :get 349 | |> conn("/posts?include=other_user.company&fields[mytype]=text,excerpt,first_character") 350 | |> Plug.Conn.assign(:data, [@default_data]) 351 | |> Plug.Conn.fetch_query_params() 352 | |> MyPostPlug.call([]) 353 | 354 | assert %{ 355 | "data" => [ 356 | %{"attributes" => attributes} 357 | ] 358 | } = Jason.decode!(conn.resp_body) 359 | 360 | assert %{ 361 | "text" => "Hello", 362 | "excerpt" => "He", 363 | "first_character" => "H" 364 | } == attributes 365 | end 366 | end 367 | 368 | describe "with a dasherized API" do 369 | setup do 370 | Application.put_env(:jsonapi, :field_transformation, :dasherize) 371 | 372 | on_exit(fn -> 373 | Application.delete_env(:jsonapi, :field_transformation) 374 | end) 375 | 376 | {:ok, []} 377 | end 378 | 379 | test "handles sparse fields properly" do 380 | conn = 381 | :get 382 | |> conn("/posts?include=other_user.company&fields[mytype]=text,first-character") 383 | |> Plug.Conn.assign(:data, [@default_data]) 384 | |> Plug.Conn.fetch_query_params() 385 | |> MyPostPlug.call([]) 386 | 387 | assert %{ 388 | "data" => [ 389 | %{"attributes" => attributes} 390 | ] 391 | } = Jason.decode!(conn.resp_body) 392 | 393 | assert %{ 394 | "text" => "Hello", 395 | "first-character" => "H" 396 | } == attributes 397 | end 398 | 399 | test "handles empty sparse fields properly" do 400 | conn = 401 | :get 402 | |> conn("/posts?include=other_user.company&fields[mytype]=") 403 | |> Plug.Conn.assign(:data, [@default_data]) 404 | |> Plug.Conn.fetch_query_params() 405 | |> MyPostPlug.call([]) 406 | 407 | assert %{ 408 | "data" => [ 409 | %{"attributes" => attributes} 410 | ] 411 | } = Jason.decode!(conn.resp_body) 412 | 413 | assert %{} == attributes 414 | end 415 | end 416 | 417 | test "omits explicit nil meta values as per http://jsonapi.org/format/#document-meta" do 418 | conn = 419 | :get 420 | |> conn("/posts") 421 | |> Plug.Conn.assign(:data, [@default_data]) 422 | |> Plug.Conn.assign(:meta, nil) 423 | |> Plug.Conn.fetch_query_params() 424 | |> MyPostPlug.call([]) 425 | 426 | json = Jason.decode!(conn.resp_body) 427 | 428 | refute Map.has_key?(json, "meta") 429 | end 430 | 431 | test "omits implicit nil meta values as per http://jsonapi.org/format/#document-meta" do 432 | conn = 433 | :get 434 | |> conn("/posts") 435 | |> Plug.Conn.assign(:data, [@default_data]) 436 | |> Plug.Conn.fetch_query_params() 437 | |> MyPostPlug.call([]) 438 | 439 | json = Jason.decode!(conn.resp_body) 440 | 441 | refute Map.has_key?(json, "meta") 442 | end 443 | end 444 | -------------------------------------------------------------------------------- /test/jsonapi/plugs/format_required_test.exs: -------------------------------------------------------------------------------- 1 | defmodule JSONAPI.FormatRequiredTest do 2 | use ExUnit.Case 3 | use Plug.Test 4 | 5 | alias JSONAPI.FormatRequired 6 | 7 | test "halts and returns an error for missing data param" do 8 | conn = 9 | :post 10 | |> conn("/example", Jason.encode!(%{})) 11 | |> call_plug 12 | 13 | assert conn.halted 14 | assert 400 == conn.status 15 | 16 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 17 | 18 | assert %{"source" => %{"pointer" => "/data"}, "title" => "Missing data parameter"} = error 19 | end 20 | 21 | test "halts and returns an error for missing attributes in data param" do 22 | conn = 23 | :post 24 | |> conn("/example", Jason.encode!(%{data: %{}})) 25 | |> call_plug 26 | 27 | assert conn.halted 28 | assert 400 == conn.status 29 | 30 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 31 | 32 | assert %{ 33 | "source" => %{"pointer" => "/data/attributes"}, 34 | "title" => "Missing attributes in data parameter" 35 | } = error 36 | end 37 | 38 | test "halts and returns an error for missing type in data param" do 39 | conn = 40 | :post 41 | |> conn("/example", Jason.encode!(%{data: %{attributes: %{}}})) 42 | |> call_plug 43 | 44 | assert conn.halted 45 | assert 400 == conn.status 46 | 47 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 48 | 49 | assert %{ 50 | "source" => %{"pointer" => "/data/type"}, 51 | "title" => "Missing type in data parameter" 52 | } = error 53 | end 54 | 55 | test "does not halt if only type member is present on a post" do 56 | conn = 57 | :post 58 | |> conn("/example", Jason.encode!(%{data: %{type: "something"}})) 59 | |> call_plug 60 | 61 | refute conn.halted 62 | end 63 | 64 | test "halts and returns an error for missing id in data param on a patch" do 65 | conn = 66 | :patch 67 | |> conn("/example", Jason.encode!(%{data: %{attributes: %{}, type: "something"}})) 68 | |> call_plug 69 | 70 | assert conn.halted 71 | assert 400 == conn.status 72 | 73 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 74 | 75 | assert %{ 76 | "source" => %{"pointer" => "/data/id"}, 77 | "title" => "Missing id in data parameter" 78 | } = error 79 | end 80 | 81 | test "halts and returns an error for missing type in data param on a patch" do 82 | conn = 83 | :patch 84 | |> conn("/example", Jason.encode!(%{data: %{attributes: %{}, id: "something"}})) 85 | |> call_plug 86 | 87 | assert conn.halted 88 | assert 400 == conn.status 89 | 90 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 91 | 92 | assert %{ 93 | "source" => %{"pointer" => "/data/type"}, 94 | "title" => "Missing type in data parameter" 95 | } = error 96 | end 97 | 98 | test "does not halt if type and id members are present on a patch" do 99 | conn = 100 | :patch 101 | |> conn("/example", Jason.encode!(%{data: %{type: "something", id: "some-identifier"}})) 102 | |> call_plug 103 | 104 | refute conn.halted 105 | end 106 | 107 | test "halts with a multi-RIO payload to a non-relationship PATCH endpoint" do 108 | :patch 109 | |> conn("/example", Jason.encode!(%{data: [%{type: "something"}]})) 110 | |> call_plug 111 | |> assert_improper_use_of_multi_rio() 112 | end 113 | 114 | test "halts with a multi-RIO payload to a non-relationship POST endpoint" do 115 | :post 116 | |> conn("/example", Jason.encode!(%{data: [%{type: "something"}]})) 117 | |> call_plug 118 | |> assert_improper_use_of_multi_rio() 119 | end 120 | 121 | test "accepts a multi-RIO payload for relationship PATCH endpoints" do 122 | conn = 123 | :patch 124 | |> conn("/example/relationships/things", Jason.encode!(%{data: [%{type: "something"}]})) 125 | |> call_plug 126 | 127 | refute conn.halted 128 | end 129 | 130 | test "accepts a multi-RIO payload for relationship POST endpoints" do 131 | conn = 132 | :post 133 | |> conn("/example/relationships/things", Jason.encode!(%{data: [%{type: "something"}]})) 134 | |> call_plug 135 | 136 | refute conn.halted 137 | end 138 | 139 | test "halts and returns an error for a specified relationships param missing an object value ON POST" do 140 | conn = 141 | :post 142 | |> conn( 143 | "/example", 144 | Jason.encode!(%{data: %{type: "example", attributes: %{}, relationships: nil}}) 145 | ) 146 | |> call_plug 147 | 148 | assert conn.halted 149 | assert 400 == conn.status 150 | 151 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 152 | 153 | assert %{ 154 | "source" => %{"pointer" => "/data/relationships"}, 155 | "title" => "Relationships parameter is not an object", 156 | "detail" => "Check out https://jsonapi.org/format/#document-resource-object-relationships for more info." 157 | } = error 158 | end 159 | 160 | test "halts and returns an error for a specified relationships param missing an object value ON PATCH" do 161 | conn = 162 | :patch 163 | |> conn( 164 | "/example/some-id", 165 | Jason.encode!(%{ 166 | data: %{id: "some-id", type: "example", attributes: %{}, relationships: nil} 167 | }) 168 | ) 169 | |> call_plug 170 | 171 | assert conn.halted 172 | assert 400 == conn.status 173 | 174 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 175 | 176 | assert %{ 177 | "source" => %{"pointer" => "/data/relationships"}, 178 | "title" => "Relationships parameter is not an object", 179 | "detail" => "Check out https://jsonapi.org/format/#document-resource-object-relationships for more info." 180 | } = error 181 | end 182 | 183 | test "halts and returns an error for relationship objects missing a data member on POST" do 184 | conn = 185 | :post 186 | |> conn( 187 | "/example", 188 | Jason.encode!(%{ 189 | data: %{ 190 | type: "example", 191 | attributes: %{}, 192 | relationships: %{comment: %{id: "some-identifier"}} 193 | } 194 | }) 195 | ) 196 | |> call_plug 197 | 198 | assert conn.halted 199 | assert 400 == conn.status 200 | 201 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 202 | 203 | assert %{ 204 | "source" => %{"pointer" => "/data/relationships/comment/data"}, 205 | "title" => "Missing data member in relationship" 206 | } = error 207 | end 208 | 209 | test "halts and returns an error for relationship objects missing a data member on PATCH" do 210 | conn = 211 | :patch 212 | |> conn( 213 | "/example/some-id", 214 | Jason.encode!(%{ 215 | data: %{ 216 | id: "some-id", 217 | type: "example", 218 | attributes: %{}, 219 | relationships: %{comment: %{id: "some-identifier"}} 220 | } 221 | }) 222 | ) 223 | |> call_plug 224 | 225 | assert conn.halted 226 | assert 400 == conn.status 227 | 228 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 229 | 230 | assert %{ 231 | "source" => %{"pointer" => "/data/relationships/comment/data"}, 232 | "title" => "Missing data member in relationship" 233 | } = error 234 | end 235 | 236 | test "halts and returns an error for relationship objects with a resource linkage missing a type member on POST" do 237 | conn = 238 | :post 239 | |> conn( 240 | "/example", 241 | Jason.encode!(%{ 242 | data: %{ 243 | type: "example", 244 | attributes: %{}, 245 | relationships: %{comment: %{data: %{id: "some-identifier"}}} 246 | } 247 | }) 248 | ) 249 | |> call_plug 250 | 251 | assert conn.halted 252 | assert 400 == conn.status 253 | 254 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 255 | 256 | assert %{ 257 | "source" => %{"pointer" => "/data/relationships/comment/data/type"}, 258 | "title" => "Missing type in relationship data parameter" 259 | } = error 260 | end 261 | 262 | test "halts and returns an error for relationship objects with a resource linkage missing a type member on PATCH" do 263 | conn = 264 | :patch 265 | |> conn( 266 | "/example/some-id", 267 | Jason.encode!(%{ 268 | data: %{ 269 | id: "some-id", 270 | type: "example", 271 | attributes: %{}, 272 | relationships: %{comment: %{data: %{id: "some-identifier"}}} 273 | } 274 | }) 275 | ) 276 | |> call_plug 277 | 278 | assert conn.halted 279 | assert 400 == conn.status 280 | 281 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 282 | 283 | assert %{ 284 | "source" => %{"pointer" => "/data/relationships/comment/data/type"}, 285 | "title" => "Missing type in relationship data parameter" 286 | } = error 287 | end 288 | 289 | test "halts and returns an error for relationship objects with a resource linkage missing an id member on POST" do 290 | conn = 291 | :post 292 | |> conn( 293 | "/example", 294 | Jason.encode!(%{ 295 | data: %{ 296 | type: "example", 297 | attributes: %{}, 298 | relationships: %{comment: %{data: %{type: "comment"}}} 299 | } 300 | }) 301 | ) 302 | |> call_plug 303 | 304 | assert conn.halted 305 | assert 400 == conn.status 306 | 307 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 308 | 309 | assert %{ 310 | "source" => %{"pointer" => "/data/relationships/comment/data/id"}, 311 | "title" => "Missing id in relationship data parameter" 312 | } = error 313 | end 314 | 315 | test "halts and returns an error for relationship objects with a resource linkage missing an id member on PATCH" do 316 | conn = 317 | :patch 318 | |> conn( 319 | "/example/some-id", 320 | Jason.encode!(%{ 321 | data: %{ 322 | id: "some-id", 323 | type: "example", 324 | attributes: %{}, 325 | relationships: %{comment: %{data: %{type: "comment"}}} 326 | } 327 | }) 328 | ) 329 | |> call_plug 330 | 331 | assert conn.halted 332 | assert 400 == conn.status 333 | 334 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 335 | 336 | assert %{ 337 | "source" => %{"pointer" => "/data/relationships/comment/data/id"}, 338 | "title" => "Missing id in relationship data parameter" 339 | } = error 340 | end 341 | 342 | test "halts and returns an error for relationship objects with a resource linkage missing both type and id members on POST" do 343 | conn = 344 | :post 345 | |> conn( 346 | "/example", 347 | Jason.encode!(%{ 348 | data: %{ 349 | type: "example", 350 | attributes: %{}, 351 | relationships: %{comment: %{data: %{}}} 352 | } 353 | }) 354 | ) 355 | |> call_plug 356 | 357 | assert conn.halted 358 | assert 400 == conn.status 359 | 360 | %{"errors" => errors} = Jason.decode!(conn.resp_body) 361 | 362 | error_titles = Enum.map(errors, fn error -> Map.fetch!(error, "title") end) 363 | assert "Missing id in relationship data parameter" in error_titles 364 | assert "Missing type in relationship data parameter" in error_titles 365 | 366 | error_pointers = Enum.map(errors, fn error -> get_in(error, ["source", "pointer"]) end) 367 | assert "/data/relationships/comment/data/id" in error_pointers 368 | assert "/data/relationships/comment/data/type" in error_pointers 369 | end 370 | 371 | test "halts and returns an error for relationship objects with a resource linkage missing both type and id members on PATCH" do 372 | conn = 373 | :patch 374 | |> conn( 375 | "/example/some-id", 376 | Jason.encode!(%{ 377 | data: %{ 378 | id: "some-id", 379 | type: "example", 380 | attributes: %{}, 381 | relationships: %{comment: %{data: %{}}} 382 | } 383 | }) 384 | ) 385 | |> call_plug 386 | 387 | assert conn.halted 388 | assert 400 == conn.status 389 | 390 | %{"errors" => errors} = Jason.decode!(conn.resp_body) 391 | 392 | error_titles = Enum.map(errors, fn error -> Map.fetch!(error, "title") end) 393 | assert "Missing id in relationship data parameter" in error_titles 394 | assert "Missing type in relationship data parameter" in error_titles 395 | 396 | error_pointers = Enum.map(errors, fn error -> get_in(error, ["source", "pointer"]) end) 397 | assert "/data/relationships/comment/data/id" in error_pointers 398 | assert "/data/relationships/comment/data/type" in error_pointers 399 | end 400 | 401 | test "accepts a relationships object with well-formed resource linkages on POST" do 402 | conn = 403 | :post 404 | |> conn( 405 | "/example", 406 | Jason.encode!(%{ 407 | data: %{ 408 | type: "example", 409 | attributes: %{}, 410 | relationships: %{ 411 | comment: %{data: %{type: "comment", id: "some-identifier"}}, 412 | post: %{data: nil}, 413 | reviews: %{data: []} 414 | } 415 | } 416 | }) 417 | ) 418 | |> call_plug 419 | 420 | refute conn.halted 421 | end 422 | 423 | test "accepts a relationships object with well-formed resource linkages on PATCH" do 424 | conn = 425 | :patch 426 | |> conn( 427 | "/example/some-id", 428 | Jason.encode!(%{ 429 | data: %{ 430 | id: "some-id", 431 | type: "example", 432 | attributes: %{}, 433 | relationships: %{ 434 | comment: %{data: %{type: "comment", id: "some-identifier"}}, 435 | post: %{data: nil}, 436 | reviews: %{data: []} 437 | } 438 | } 439 | }) 440 | ) 441 | |> call_plug 442 | 443 | refute conn.halted 444 | end 445 | 446 | test "passes request through" do 447 | conn = 448 | :post 449 | |> conn("/example", Jason.encode!(%{data: %{type: "something"}})) 450 | |> call_plug 451 | 452 | refute conn.halted 453 | end 454 | 455 | defp assert_improper_use_of_multi_rio(conn) do 456 | assert conn.halted 457 | assert 400 == conn.status 458 | 459 | %{"errors" => [error]} = Jason.decode!(conn.resp_body) 460 | 461 | assert %{ 462 | "detail" => "Check out https://jsonapi.org/format/#crud-updating-to-many-relationships for more info.", 463 | "source" => %{"pointer" => "/data"}, 464 | "status" => "400", 465 | "title" => "Data parameter has multiple Resource Identifier Objects for a non-relationship endpoint" 466 | } = error 467 | end 468 | 469 | defp call_plug(conn) do 470 | parser_opts = Plug.Parsers.init(parsers: [:json], pass: ["text/*"], json_decoder: Jason) 471 | 472 | conn 473 | |> Plug.Conn.put_req_header("content-type", "application/json") 474 | |> Plug.Parsers.call(parser_opts) 475 | |> FormatRequired.call([]) 476 | end 477 | end 478 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## NEXT 4 | ... 5 | 6 | ## [1.10.0](https://github.com/beam-community/jsonapi/compare/v1.9.0...v1.10.0) (2025-08-19) 7 | 8 | 9 | ### Features 10 | 11 | * add polymorphic resource support ([#359](https://github.com/beam-community/jsonapi/issues/359)) ([0ea2996](https://github.com/beam-community/jsonapi/commit/0ea2996a5317d9db84e9fac65e9ce7431f726ba4)) 12 | 13 | ## [1.9.0](https://github.com/beam-community/jsonapi/compare/v1.8.4...v1.9.0) (2025-05-18) 14 | 15 | 16 | ### Features 17 | 18 | * support nested filtering in the query parser ([#366](https://github.com/beam-community/jsonapi/issues/366)) ([63d297c](https://github.com/beam-community/jsonapi/commit/63d297c068bdbe1ef7984e2713bf0fbb71395b9e)) 19 | 20 | ## [1.8.4](https://github.com/beam-community/jsonapi/compare/v1.8.3...v1.8.4) 21 | 22 | 23 | ### Bug Fixes 24 | 25 | * fix: bug with DataToParams include processing expecting more 'data' keys than the spec calls for [#363](https://github.com/beam-community/jsonapi/pull/363) 26 | 27 | ## [1.8.3](https://github.com/beam-community/jsonapi/compare/v1.8.2...v1.8.3) (2024-11-04) 28 | 29 | 30 | ### Bug Fixes 31 | 32 | * camelize when prefix is camelized already ([ae78a7d](https://github.com/beam-community/jsonapi/commit/ae78a7de4dd1960437d23b2c3177bd1cf721df4e)) 33 | * Compiler warning for zero-arity fields function call ([b071131](https://github.com/beam-community/jsonapi/commit/b0711319d4473beab58b5bb68dac87f7b5f9daeb)) 34 | 35 | ## [1.8.2](https://github.com/beam-community/jsonapi/compare/v1.8.1...v1.8.2) (2024-09-22) 36 | 37 | 38 | ### Bug Fixes 39 | 40 | * Compiler warnings for zero-arity funtion calls ([#335](https://github.com/beam-community/jsonapi/issues/335)) ([49ed6ab](https://github.com/beam-community/jsonapi/commit/49ed6ab453cdd7af44f608615daf147da876841a)) 41 | 42 | ## [1.8.1](https://github.com/beam-community/jsonapi/compare/1.8.0...v1.8.1) 43 | 44 | ### Bug Fixes 45 | 46 | * Fix incorrect ordering of serialized lists ([#333](https://github.com/beam-community/jsonapi/pull/333)) 47 | 48 | 49 | ## [1.8.0](https://github.com/beam-community/jsonapi/compare/1.7.1...v1.8.0) (2024-06-25) 50 | 51 | 52 | ### Features 53 | 54 | * Link Objects and Additional Controller Actions ([#264](https://github.com/beam-community/jsonapi/issues/264)) ([def58a9](https://github.com/beam-community/jsonapi/commit/def58a9bb6c10c8e72a7f3e7b86ad04748331204)) 55 | * Setup common config ([#322](https://github.com/beam-community/jsonapi/issues/322)) ([702b248](https://github.com/beam-community/jsonapi/commit/702b2488cb4b683ae9b405c33979500536e0ef2a)) 56 | 57 | 58 | ### Bug Fixes 59 | 60 | * Add release please manifest ([3d3ca63](https://github.com/beam-community/jsonapi/commit/3d3ca634b212b394209c498f063999a22549c950)) 61 | * Fix failing credo check ([e025dc2](https://github.com/beam-community/jsonapi/commit/e025dc2dc5213a6d17cc1d005824f13f60938fe4)) 62 | 63 | ## 1.7.1 (2024-02-23) 64 | * Fix bug where underscore/dasherize misses single characters by @protestContest in https://github.com/beam-community/jsonapi/pull/316 65 | * Transform relationship keys with shallow field transformation options (#314) by @protestContest in https://github.com/beam-community/jsonapi/pull/315 66 | 67 | **Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.7.0...1.7.1 68 | 69 | ## 1.7.0 (2024-02-15) 70 | 71 | ## What's Changed 72 | * Add options to transform field keys non-recursively (#132) by @protestContest in https://github.com/beam-community/jsonapi/pull/310 73 | 74 | **Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.6.3...1.7.0 75 | 76 | ## 1.6.3 (2023-08-03) 77 | 78 | ### What's Changed 79 | * Nested query filter fixes by @TylerPachal in https://github.com/beam-community/jsonapi/pull/302 80 | 81 | **Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.6.2...1.6.3 82 | 83 | ## 1.6.2 (2023-07-03) 84 | 85 | ### What's Changed 86 | * Error handling fixed per https://github.com/beam-community/jsonapi/issues/294. 87 | 88 | **Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.6.1...1.6.2 89 | 90 | ## 1.6.1 (2023-06-26) 91 | 92 | ### What's Changed 93 | The features of #270 were broken in two ways that this release fixes. 94 | 95 | 1. The `@spec` for the `relationships` `callback` for `JSONAPI.View` actually did not allow for the various new structures a `relationships` `callback` is allowed to return under the above PR. 96 | 2. The PR was intended to support (among other more general purposes) remapping of an `attribute` field to a `relationship` -- this is niche, but sometimes quite useful. The above PR and its tests failed to fully realize that goal by missing one small detail (lost in a merge conflict resolution, as it turns out). 97 | 98 | **Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.6.0...1.6.1 99 | 100 | ## 1.6.0 (2023-06-12) 101 | 102 | ### What's Changed 103 | * Add support for a JSON:API includes allowlist. by @mattpolzin in https://github.com/beam-community/jsonapi/pull/292 104 | 105 | **Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.5.1...1.6.0 106 | 107 | ## 1.5.1 (2023-05-19) 108 | 109 | ### What's Changed 110 | * Change camelize behavior by @TylerPachal in https://github.com/beam-community/jsonapi/pull/293 111 | 112 | Specifically, already-camilized strings will no longer be turned to all-lowercase by the `:camelize` transformation; they will be left alone. 113 | 114 | **Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.5.0...1.5.1 115 | 116 | ## 1.5.0 (2023-01-25) 117 | 118 | ### What's Changed 119 | 120 | #### Improvements 121 | 122 | * Integration between UnderscoreParameters and QueryParser by @TylerPachal in https://github.com/beam-community/jsonapi/pull/282 123 | * Response body for content type error by @TylerPachal in https://github.com/beam-community/jsonapi/pull/276 124 | * Fix typos by @kianmeng in https://github.com/beam-community/jsonapi/pull/275 125 | * Add c:JSONAPI.View.get_field/3 by @whatyouhide in https://github.com/beam-community/jsonapi/pull/273 126 | * Support renaming of relationships by @mattpolzin in https://github.com/beam-community/jsonapi/pull/270 127 | 128 | ### New Contributors 129 | 130 | * @kianmeng made their first contribution in https://github.com/beam-community/jsonapi/pull/275 131 | * @whatyouhide made their first contribution in https://github.com/beam-community/jsonapi/pull/273 132 | * @TylerPachal made their first contribution in https://github.com/beam-community/jsonapi/pull/276 133 | 134 | **Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.4.0...v1.5.0 135 | 136 | ## 1.4.0 (2022-11-05) 137 | 138 | **Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.3.0...v1.4.0 139 | 140 | ## 1.3.0 (2020-03-21) 141 | 142 | ### Added 143 | 144 | - [Add Deserializer Plug](https://github.com/jeregrine/jsonapi/pull/222) 145 | 146 | ### Changed 147 | 148 | - [Continuous](https://github.com/jeregrine/jsonapi/pull/226) [Integration](https://github.com/jeregrine/jsonapi/pull/227) 149 | with Github actions. 150 | - ["self" URL can include query parameters](https://github.com/jeregrine/jsonapi/pull/224) 151 | 152 | ### Contributors 153 | 154 | A healthy Covid-19 safe foot-tap to: @CostantiniMatteo, @lucacorti, @snewcomer, and @jherdman 155 | 156 | ## 1.2.3 (2020-01-28) 157 | 158 | ### Added 159 | 160 | N/A 161 | 162 | ### Changed 163 | 164 | - [Fixed documentation typo](https://github.com/jeregrine/jsonapi/pull/213) 165 | - [Added query parameters to links](https://github.com/jeregrine/jsonapi/pull/214) 166 | - [Added installation instructions](https://github.com/jeregrine/jsonapi/pull/216) 167 | 168 | ### Contributors 169 | 170 | Big ups to @jgelens, @komlanvi, @ryanlabouve 171 | 172 | ## 1.2.2 (2019-09-28) 173 | 174 | ### Added 175 | 176 | N/A 177 | 178 | ### Changed 179 | 180 | - [Removed Elixir 1.5 from the Build Matrix](https://github.com/jeregrine/jsonapi/pull/212) 181 | - [Fixed underscoring camelCase params](https://github.com/jeregrine/jsonapi/pull/211) 182 | 183 | ## 1.2.1 (2019-06-27) 184 | 185 | ### Added 186 | 187 | N/A 188 | 189 | ### Changed 190 | 191 | - [Updated example pagination code](https://github.com/jeregrine/jsonapi/pull/204) 192 | - [Fixed sparse fieldset compliance for relationships](https://github.com/jeregrine/jsonapi/pull/203) 193 | - [Error status codes](https://github.com/jeregrine/jsonapi/pull/206) in error responses should be Strings 194 | - [Fixed a problem](https://github.com/jeregrine/jsonapi/pull/208) with "self" pagination link 195 | 196 | ### Contributors 197 | 198 | A fist bump of appreciation to @lucacorti, @leepfrog, @jasondew, and @thebrianemory. 199 | 200 | ## 1.2.0 (2019-04-29) 201 | 202 | ### Added 203 | 204 | - Pagination has had a massive overhaul. Docs have been updated. Please file issues 205 | should you run into any problems. You may wish to review 206 | [the pull request](https://github.com/jeregrine/jsonapi/pull/189) for more details. 207 | - [More typespecs](https://github.com/jeregrine/jsonapi/pull/198) 208 | - `EnsureSpec` Plug now sets the JSON:API response content type 209 | [for you](https://github.com/jeregrine/jsonapi/pull/185). This means you need 210 | not manually include the `ResponseContentType` Plug in your pipeline should you 211 | already have `EnsureSpec` in play. Please see the documentation for 212 | `ResponseContentType` should you wish to override it for a specific end-point. 213 | 214 | ### Changed 215 | 216 | - Ex Doc was updated to leverage some of its fancy new features. 217 | - `EnsureSpec` pipeline checks to ensure that 218 | [a PATCH request has an ID](https://github.com/jeregrine/jsonapi/commit/86d98d9dc0ddd29143b9da1a6522acfbcb8bb904) 219 | - Documentation improvements 220 | 221 | ### Contributors 222 | 223 | Much love to: @0urobor0s, @kbaird, @lucacorti, @strzibny 224 | 225 | ## 1.1.0 (2019-02-23) 226 | 227 | ### Added 228 | 229 | - Various typespec changes 230 | - The `:namespace` option is [globally configurable](https://github.com/jeregrine/jsonapi/pull/178) 231 | - Fully support [sparse fieldsets](https://github.com/jeregrine/jsonapi/pull/171) 232 | 233 | ### Changed 234 | 235 | - [Removed](https://github.com/jeregrine/jsonapi/pull/172) `Config.required_fields` 236 | - Documentation improvements 237 | 238 | ### Fixes 239 | 240 | - Credo is set to use [strict option](https://github.com/jeregrine/jsonapi/pull/177) 241 | - `FormatRequired` Plug [accepts a legal RIO payload](https://github.com/jeregrine/jsonapi/pull/176) 242 | - Report on [missing data type as such](https://github.com/jeregrine/jsonapi/pull/180) 243 | 244 | ### Contributors 245 | 246 | Much love to @kbaird, and @zamith. 247 | 248 | ## 1.0.0 (2019-01-27) 249 | 250 | Hot on the heels of 0.9.0, 1.0.0 is here! Please remember to upgrade to 0.9.0 251 | first. You'll find the upgrade path much easier. 252 | 253 | - [Added](https://github.com/jeregrine/jsonapi/pull/170) run Dialyzer on CI 254 | - [Fixed](https://github.com/jeregrine/jsonapi/issues/134) bad includes result in HTTP 500 response 255 | - [Removed](https://github.com/jeregrine/jsonapi/pull/163) all deprecated code 256 | - [Added](https://github.com/jeregrine/jsonapi/pull/158) `camelCase` field support for JSON:API v1.1 257 | - [Added](https://github.com/jeregrine/jsonapi/pull/164) support for arbitrary links 258 | - [Added](https://github.com/jeregrine/jsonapi/pull/161) Elixir 1.8 to the build matrix 259 | 260 | ## 0.9.0 (2019-01-18) 261 | 262 | This is the last release before 1.0. As such this release will feature a number 263 | of deprecations that you'll want to either resolve before upgrading. Should 264 | you have any trouble with these deprecations please file an issue. 265 | 266 | - [Added](https://github.com/jeregrine/jsonapi/pull/151) Expand Build Matrix Again 267 | - [Added](https://github.com/jeregrine/jsonapi/pull/155) Refactor String Manipulation Utility Module 268 | - [Internal](https://github.com/jeregrine/jsonapi/pull/152) Move `QueryParser` Test 269 | - [Added](https://github.com/jeregrine/jsonapi/pull/151) Expand Build Matrix 270 | - [Added](https://github.com/jeregrine/jsonapi/pull/149) Add Plug to Transform Parameters 271 | - [Fixed](https://github.com/jeregrine/jsonapi/pull/148) Namespace `Deprecation` module 272 | - [Internal](https://github.com/jeregrine/jsonapi/pull/146) Consolidate Plug Locations 273 | - [Fixed](https://github.com/jeregrine/jsonapi/pull/144) Set `Content-Type` for errors 274 | - [Internal](https://github.com/jeregrine/jsonapi/pull/140) Improve `Application.env` handling in tests 275 | - [Fixed](https://github.com/jeregrine/jsonapi/pull/139) Update regexes for underscore and dash 276 | - [Internal](https://github.com/jeregrine/jsonapi/pull/135) Remove leading `is_` from `is_data_loaded?` 277 | - [Fixed](https://github.com/jeregrine/jsonapi/pull/129) Remove warning about hidden being undefined 278 | - [Added](https://github.com/jeregrine/jsonapi/pull/126) Allows for conditionally hiding fields 279 | - [Fixed](https://github.com/jeregrine/jsonapi/pull/124) Omit non-Object meta 280 | 281 | ## v0.7.0-0.8.0 (2018-06-13) 282 | 283 | (Sorry I missed 0.7.0) 284 | 285 | - [Added](https://github.com/jeregrine/jsonapi/pull/117/commits/09faf424f47d46a9f2d24c3057c11c961d345990) Support for configuring your JSON Library, and defaulted to Jason going forward. 286 | - [Fixed](https://github.com/jeregrine/jsonapi/pull/87) Fix nesting for includes 287 | - [Added](https://github.com/jeregrine/jsonapi/pull/88) Removing Top Level if configured 288 | - [Fixed](https://github.com/jeregrine/jsonapi/pull/90) Check headers according to spec 289 | - [Added](https://github.com/jeregrine/jsonapi/pull/92) Add to view custom attribute 290 | - [Added](https://github.com/jeregrine/jsonapi/pull/93) updates plug to allow data with only relationships enhancement 291 | - [Added](https://github.com/jeregrine/jsonapi/pull/97) include meta as top level document member 292 | - [Added](https://github.com/jeregrine/jsonapi/pull/102) Apply optional dash-to-underscore to include keys 293 | - [Fixed](https://github.com/jeregrine/jsonapi/pull/103) Do not build relationships section for not loaded relationships 294 | - [Fixed](https://github.com/jeregrine/jsonapi/pull/105) change try/rescue to function_exported? and update docs 295 | - [Fixed](https://github.com/jeregrine/jsonapi/pull/106) Dasherize keys in relationship urls 296 | - [Added](https://github.com/jeregrine/jsonapi/pull/107) Allows the view to add links 297 | - [Fixed](https://github.com/jeregrine/jsonapi/pull/113) Serialize empty relationship 298 | - [Fixed](https://github.com/jeregrine/jsonapi/pull/114) Handle dashed include for top-level relationship 299 | 300 | ## v0.6.0 (2017-11-17) 301 | 302 | - [Added](https://github.com/jeregrine/jsonapi/commit/44888596461a1891376b937057bb504345cff8dc) Optional Data Links. 303 | - [Added](https://github.com/jeregrine/jsonapi/commit/ba9d9cb84c10ef85a4b8e42df88a9e92f3809651) Paging Support 304 | - [Added](https://github.com/jeregrine/jsonapi/commit/0c50bc60db9b8678f631ac274062150499e4fb8b) Option to replace underscores with dahses 305 | 306 | ## v0.5.1 (2017-07-07) 307 | 308 | - [Added](https://github.com/jeregrine/jsonapi/commit/1f9e45aee4058ca6b3a8a55aaec6eebcada525a6) plug to make verifying requests and their errors easier 309 | 310 | ## v0.5.0 (2017-07-07) 311 | 312 | - [Added](https://github.com/jeregrine/jsonapi/commit/def022b327ac13e5e906a665321969b442048f3b) support for meta fields 313 | - [Added](https://github.com/jeregrine/jsonapi/commit/1bbe4de86baec250d0b8dcc263bb41a94dea8063) support for custom hosts 314 | - [Added](https://github.com/jeregrine/jsonapi/commit/3c73e870651f09ce8e09d4061111487db2e515f5) support for hidden attributes in views 315 | - [Added](https://github.com/jeregrine/jsonapi/commit/45f0d14e9d700d32a8b20dc04a4fa300fa43da37) support conversion of underscore to dashes. 316 | - [Fixed](https://github.com/jeregrine/jsonapi/commit/74b0d1914a3aceb792c753f2292002c10ac93005) issue with index.json 317 | - Now uses Credo 318 | 319 | ## v0.4.2 (2017-04-17) 320 | 321 | - Updated codebase for elixir 1.4 322 | - Updated poison dep to 3.0 323 | - Fixed failing travis tests 324 | 325 | ## v0.4.0 (2016-12-02) 326 | 327 | - Removed PhoenixView 328 | 329 | ## v0.1.0 (2015-06-?) 330 | 331 | - Made params optional 332 | 333 | ## v0.0.2 (2015-06-22) 334 | 335 | - Made paging optional 336 | 337 | ## v0.0.1 (2015-06-21) 338 | 339 | - Support for basic JSONAPI Docs. Links support still missing 340 | -------------------------------------------------------------------------------- /lib/jsonapi/view.ex: -------------------------------------------------------------------------------- 1 | # credo:disable-for-this-file Credo.Check.Refactor.LongQuoteBlocks 2 | defmodule JSONAPI.View do 3 | @moduledoc """ 4 | A View is simply a module that defines certain callbacks to configure proper 5 | rendering of your JSONAPI documents. 6 | 7 | defmodule PostView do 8 | use JSONAPI.View 9 | 10 | def fields, do: [:id, :text, :body] 11 | def type, do: "post" 12 | def relationships do 13 | [author: UserView, 14 | comments: CommentView] 15 | end 16 | end 17 | 18 | defmodule UserView do 19 | use JSONAPI.View 20 | 21 | def fields, do: [:id, :username] 22 | def type, do: "user" 23 | def relationships, do: [] 24 | end 25 | 26 | defmodule CommentView do 27 | use JSONAPI.View 28 | 29 | def fields, do: [:id, :text] 30 | def type, do: "comment" 31 | def relationships do 32 | [user: {UserView, :include}] 33 | end 34 | end 35 | 36 | defmodule DogView do 37 | use JSONAPI.View, namespace: "/pupperz-api" 38 | end 39 | 40 | You can now call `UserView.show(user, conn, conn.params)` and it will render 41 | a valid jsonapi doc. 42 | 43 | ## Fields 44 | 45 | By default, the resulting JSON document consists of fields, defined in the `fields/0` 46 | function. You can define custom fields or override current fields by defining a 47 | 2-arity function inside the view that takes `data` and `conn` as arguments and has 48 | the same name as the field it will be producing. Refer to our `fullname/2` example below. 49 | 50 | defmodule UserView do 51 | use JSONAPI.View 52 | 53 | def fullname(data, conn), do: "fullname" 54 | 55 | def fields, do: [:id, :username, :fullname] 56 | def type, do: "user" 57 | def relationships, do: [] 58 | end 59 | 60 | Fields may be omitted manually using the `hidden/1` function. 61 | 62 | defmodule UserView do 63 | use JSONAPI.View 64 | 65 | def fields, do: [:id, :username, :email] 66 | 67 | def type, do: "user" 68 | 69 | def hidden(_data) do 70 | [:email] # will be removed from the response 71 | end 72 | end 73 | 74 | In order to use [sparse fieldsets](https://jsonapi.org/format/#fetching-sparse-fieldsets) 75 | you must include the `JSONAPI.QueryParser` plug. 76 | 77 | If you want to fetch fields from the given data *dynamically*, you can use the 78 | `c:get_field/3` callback. 79 | 80 | defmodule UserView do 81 | use JSONAPI.View 82 | 83 | def fields, do: [:id, :username, :email] 84 | 85 | def type, do: "user" 86 | 87 | def get_field(field, data, _conn) do 88 | Map.fetch!(data, field) 89 | end 90 | end 91 | 92 | ## Relationships 93 | 94 | Currently the relationships callback expects that a map is returned 95 | configuring the information you will need. If you have the following Ecto 96 | Model setup 97 | 98 | defmodule User do 99 | schema "users" do 100 | field :username 101 | has_many :posts 102 | has_one :image 103 | end 104 | end 105 | 106 | and the includes setup from above. If your Post has loaded the author and the 107 | query asks for it then it will be loaded. 108 | 109 | So for example: 110 | `GET /posts?include=post.author` if the author record is loaded on the Post, and you are using 111 | the `JSONAPI.QueryParser` it will be included in the `includes` section of the JSONAPI document. 112 | 113 | If you always want to include a relationship. First make sure its always preloaded 114 | and then use the `[user: {UserView, :include}]` syntax in your `includes` function. This tells 115 | the serializer to *always* include if its loaded. 116 | 117 | ## Polymorphic Resources 118 | 119 | Polymorphic resources allow you to serialize different types of data with the same view module. 120 | This is useful when you have a collection of resources that share some common attributes but 121 | have different types, fields, or relationships based on the specific data being serialized. 122 | 123 | To enable polymorphic resources, set `polymorphic_resource?: true` when using the JSONAPI.View: 124 | 125 | defmodule MediaView do 126 | use JSONAPI.View, polymorphic_resource?: true 127 | 128 | def polymorphic_type(%{type: "image"}), do: "image" 129 | def polymorphic_type(%{type: "video"}), do: "video" 130 | def polymorphic_type(%{type: "audio"}), do: "audio" 131 | 132 | def polymorphic_fields(%{type: "image"}), do: [:id, :url, :width, :height, :alt_text] 133 | def polymorphic_fields(%{type: "video"}), do: [:id, :url, :duration, :thumbnail] 134 | def polymorphic_fields(%{type: "audio"}), do: [:id, :url, :duration, :bitrate] 135 | 136 | def polymorphic_relationships(%{type: "image"}), do: [album: AlbumView] 137 | def polymorphic_relationships(%{type: "video"}), do: [playlist: PlaylistView, author: UserView] 138 | def polymorphic_relationships(%{type: "audio"}), do: [album: AlbumView, artist: ArtistView] 139 | end 140 | 141 | ### Required Callbacks for Polymorphic Resources 142 | 143 | When using polymorphic resources, you must implement these callbacks instead of their non-polymorphic counterparts: 144 | 145 | - `polymorphic_type/1` - Returns the JSONAPI type string based on the data 146 | - `polymorphic_fields/1` - Returns the list of fields to serialize based on the data 147 | 148 | ### Optional Callbacks for Polymorphic Resources 149 | 150 | - `polymorphic_relationships/1` - Returns relationships specific to the data type (defaults to empty list) 151 | 152 | ### Example Usage 153 | 154 | With the above `MediaView`, you can serialize different media types: 155 | 156 | # Image data 157 | image = %{id: 1, type: "image", url: "/image.jpg", width: 800, height: 600, alt_text: "A photo"} 158 | MediaView.show(image, conn) 159 | # => %{data: %{id: "1", type: "image", attributes: %{url: "/image.jpg", width: 800, height: 600, alt_text: "A photo"}}} 160 | 161 | # Video data 162 | video = %{id: 2, type: "video", url: "/video.mp4", duration: 120, thumbnail: "/thumb.jpg"} 163 | MediaView.show(video, conn) 164 | # => %{data: %{id: "2", type: "video", attributes: %{url: "/video.mp4", duration: 120, thumbnail: "/thumb.jpg"}}} 165 | 166 | ### Custom Field Functions 167 | 168 | You can still define custom field functions that work across all polymorphic types: 169 | 170 | defmodule MediaView do 171 | use JSONAPI.View, polymorphic_resource?: true 172 | 173 | def file_size(data, _conn) do 174 | # Custom logic to calculate file size 175 | calculate_file_size(data.url) 176 | end 177 | 178 | def polymorphic_fields(%{type: "image"}), do: [:id, :url, :file_size, :width, :height] 179 | def polymorphic_fields(%{type: "video"}), do: [:id, :url, :file_size, :duration] 180 | # ... other polymorphic implementations 181 | end 182 | 183 | ### Notes 184 | 185 | - When `polymorphic_resource?: true` is set, the regular `type/0`, `fields/0`, and `relationships/0` 186 | functions are not used and will return default values (nil or empty list) 187 | - The polymorphic callbacks receive the actual data as their first argument, allowing you to 188 | determine the appropriate type, fields, and relationships dynamically 189 | - All other view functionality (links, meta, hidden fields, etc.) works the same way 190 | - **Important**: Polymorphic resources currently do not work for deserializing data from POST 191 | requests yet. They are only supported for serialization (rendering responses) 192 | 193 | ## Options 194 | * `:host` (binary) - Allows the `host` to be overridden for generated URLs. Defaults to `host` of the supplied `conn`. 195 | 196 | * `:scheme` (atom) - Enables configuration of the HTTP scheme for generated URLS. Defaults to `scheme` from the provided `conn`. 197 | 198 | * `:namespace` (binary) - Allows the namespace of a given resource. This may be 199 | configured globally or overridden on the View itself. Note that if you have 200 | a globally defined namespace and need to *remove* the namespace for a 201 | resource, set the namespace to a blank String. 202 | 203 | The default behaviour for `host` and `scheme` is to derive it from the `conn` provided, while the 204 | default style for presentation in names is to be underscored and not dashed. 205 | """ 206 | 207 | alias JSONAPI.{Paginator, Utils} 208 | alias Plug.Conn 209 | 210 | @type t :: module() 211 | @type data :: any() 212 | @type field :: atom() 213 | @type link_object :: %{required(:href) => String.t(), optional(:meta) => meta()} 214 | @type links :: %{atom() => String.t() | link_object()} 215 | @type meta :: %{atom() => String.t()} 216 | @type options :: keyword() 217 | @type resource_id :: String.t() 218 | @type resource_type :: String.t() 219 | @type resource_relationships :: [{atom(), t() | {t(), :include} | {atom(), t()} | {atom(), t(), :include}}] 220 | @type resource_fields :: [field()] 221 | 222 | @callback attributes(data(), Conn.t() | nil) :: map() 223 | @callback id(data()) :: resource_id() | nil 224 | @callback fields() :: resource_fields() 225 | @callback polymorphic_fields(data()) :: resource_fields() 226 | @callback get_field(field(), data(), Conn.t()) :: any() 227 | @callback hidden(data()) :: [field()] 228 | @callback links(data(), Conn.t()) :: links() 229 | @callback meta(data(), Conn.t()) :: meta() | nil 230 | @callback namespace() :: String.t() 231 | @callback pagination_links(data(), Conn.t(), Paginator.page(), Paginator.options()) :: 232 | Paginator.links() 233 | @callback path() :: String.t() | nil 234 | @callback relationships() :: resource_relationships() 235 | @callback polymorphic_relationships(data()) :: resource_relationships() 236 | @callback type() :: resource_type() | nil 237 | @callback polymorphic_type(data()) :: resource_type() | nil 238 | @callback url_for(data(), Conn.t() | nil) :: String.t() 239 | @callback url_for_pagination(data(), Conn.t(), Paginator.params()) :: String.t() 240 | @callback url_for_rel(term(), String.t(), Conn.t() | nil) :: String.t() 241 | @callback visible_fields(data(), Conn.t() | nil) :: list(atom) 242 | 243 | @optional_callbacks [get_field: 3] 244 | 245 | defmacro __using__(opts \\ []) do 246 | {type, opts} = Keyword.pop(opts, :type) 247 | {namespace, opts} = Keyword.pop(opts, :namespace) 248 | {path, opts} = Keyword.pop(opts, :path) 249 | {paginator, opts} = Keyword.pop(opts, :paginator) 250 | {polymorphic_resource?, _opts} = Keyword.pop(opts, :polymorphic_resource?, false) 251 | 252 | quote do 253 | alias JSONAPI.{Serializer, View} 254 | 255 | @behaviour View 256 | 257 | @resource_type unquote(type) 258 | @namespace unquote(namespace) 259 | @path unquote(path) 260 | @paginator unquote(paginator) 261 | @polymorphic_resource? unquote(polymorphic_resource?) 262 | 263 | @impl View 264 | def id(nil), do: nil 265 | def id(%{__struct__: Ecto.Association.NotLoaded}), do: nil 266 | def id(%{id: id}), do: to_string(id) 267 | 268 | @impl View 269 | def attributes(data, conn) do 270 | visible_fields = View.visible_fields(__MODULE__, data, conn) 271 | 272 | Enum.reduce(visible_fields, %{}, fn field, intermediate_map -> 273 | value = 274 | cond do 275 | function_exported?(__MODULE__, field, 2) -> 276 | apply(__MODULE__, field, [data, conn]) 277 | 278 | function_exported?(__MODULE__, :get_field, 3) -> 279 | apply(__MODULE__, :get_field, [field, data, conn]) 280 | 281 | true -> 282 | Map.get(data, field) 283 | end 284 | 285 | Map.put(intermediate_map, field, value) 286 | end) 287 | end 288 | 289 | cond do 290 | !@polymorphic_resource? -> 291 | @impl View 292 | def fields, do: raise("Need to implement fields/0") 293 | 294 | @impl View 295 | def polymorphic_fields(_data), do: [] 296 | 297 | @polymorphic_resource? -> 298 | @impl View 299 | def fields, do: [] 300 | 301 | @impl View 302 | def polymorphic_fields(_data), do: raise("Need to implement polymorphic_fields/1") 303 | end 304 | 305 | @impl View 306 | def hidden(_data), do: [] 307 | 308 | @impl View 309 | def links(_data, _conn), do: %{} 310 | 311 | @impl View 312 | def meta(_data, _conn), do: nil 313 | 314 | @impl View 315 | if @namespace do 316 | def namespace, do: @namespace 317 | else 318 | def namespace, do: Application.get_env(:jsonapi, :namespace, "") 319 | end 320 | 321 | @impl View 322 | def pagination_links(data, conn, page, options) do 323 | paginator = Application.get_env(:jsonapi, :paginator, @paginator) 324 | 325 | if Code.ensure_loaded?(paginator) && function_exported?(paginator, :paginate, 5) do 326 | paginator.paginate(data, __MODULE__, conn, page, options) 327 | else 328 | %{} 329 | end 330 | end 331 | 332 | @impl View 333 | def path, do: @path 334 | 335 | @impl View 336 | def relationships, do: [] 337 | 338 | @impl View 339 | def polymorphic_relationships(_data), do: [] 340 | 341 | cond do 342 | @resource_type -> 343 | @impl View 344 | def type, do: @resource_type 345 | 346 | @impl View 347 | def polymorphic_type(_data), do: nil 348 | 349 | !@polymorphic_resource? -> 350 | @impl View 351 | def type, do: raise("Need to implement type/0") 352 | 353 | @impl View 354 | def polymorphic_type(_data), do: nil 355 | 356 | @polymorphic_resource? -> 357 | @impl View 358 | def type, do: nil 359 | 360 | @impl View 361 | def polymorphic_type(_data), do: raise("Need to implement polymorphic_type/1") 362 | end 363 | 364 | @impl View 365 | def url_for(data, conn), 366 | do: View.url_for(__MODULE__, data, conn) 367 | 368 | @impl View 369 | def url_for_pagination(data, conn, pagination_params), 370 | do: View.url_for_pagination(__MODULE__, data, conn, pagination_params) 371 | 372 | @impl View 373 | def url_for_rel(data, rel_type, conn), 374 | do: View.url_for_rel(__MODULE__, data, rel_type, conn) 375 | 376 | @impl View 377 | def visible_fields(data, conn), 378 | do: View.visible_fields(__MODULE__, data, conn) 379 | 380 | def resource_fields(data) do 381 | if @polymorphic_resource? do 382 | polymorphic_fields(data) 383 | else 384 | fields() 385 | end 386 | end 387 | 388 | def resource_type(data) do 389 | if @polymorphic_resource? do 390 | polymorphic_type(data) 391 | else 392 | type() 393 | end 394 | end 395 | 396 | def resource_relationships(data) do 397 | if @polymorphic_resource? do 398 | polymorphic_relationships(data) 399 | else 400 | relationships() 401 | end 402 | end 403 | 404 | defoverridable View 405 | 406 | def index(models, conn, _params, meta \\ nil, options \\ []), 407 | do: Serializer.serialize(__MODULE__, models, conn, meta, options) 408 | 409 | def show(model, conn, _params, meta \\ nil, options \\ []), 410 | do: Serializer.serialize(__MODULE__, model, conn, meta, options) 411 | 412 | def update(model, conn, _params, meta \\ nil, options \\ []), 413 | do: Serializer.serialize(__MODULE__, model, conn, meta, options) 414 | 415 | def delete(model, conn, _params, meta \\ nil, options \\ []), 416 | do: Serializer.serialize(__MODULE__, model, conn, meta, options) 417 | 418 | def create(model, conn, _params, meta \\ nil, options \\ []), 419 | do: Serializer.serialize(__MODULE__, model, conn, meta, options) 420 | 421 | if Code.ensure_loaded?(Phoenix) do 422 | def render("show.json", %{data: data, conn: conn, meta: meta, options: options}), 423 | do: Serializer.serialize(__MODULE__, data, conn, meta, options) 424 | 425 | def render("show.json", %{data: data, conn: conn, meta: meta}), 426 | do: Serializer.serialize(__MODULE__, data, conn, meta) 427 | 428 | def render("show.json", %{data: data, conn: conn}), 429 | do: Serializer.serialize(__MODULE__, data, conn) 430 | 431 | def render("index.json", %{data: data, conn: conn, meta: meta, options: options}), 432 | do: Serializer.serialize(__MODULE__, data, conn, meta, options) 433 | 434 | def render("index.json", %{data: data, conn: conn, meta: meta}), 435 | do: Serializer.serialize(__MODULE__, data, conn, meta) 436 | 437 | def render("index.json", %{data: data, conn: conn}), 438 | do: Serializer.serialize(__MODULE__, data, conn) 439 | 440 | def render("create.json", %{data: data, conn: conn, meta: meta, options: options}), 441 | do: Serializer.serialize(__MODULE__, data, conn, meta, options) 442 | 443 | def render("create.json", %{data: data, conn: conn, meta: meta}), 444 | do: Serializer.serialize(__MODULE__, data, conn, meta) 445 | 446 | def render("create.json", %{data: data, conn: conn}), 447 | do: Serializer.serialize(__MODULE__, data, conn) 448 | 449 | def render("update.json", %{data: data, conn: conn, meta: meta, options: options}), 450 | do: Serializer.serialize(__MODULE__, data, conn, meta, options) 451 | 452 | def render("update.json", %{data: data, conn: conn, meta: meta}), 453 | do: Serializer.serialize(__MODULE__, data, conn, meta) 454 | 455 | def render("update.json", %{data: data, conn: conn}), 456 | do: Serializer.serialize(__MODULE__, data, conn) 457 | 458 | def render("delete.json", %{data: data, conn: conn, meta: meta, options: options}), 459 | do: Serializer.serialize(__MODULE__, data, conn, meta, options) 460 | 461 | def render("delete.json", %{data: data, conn: conn, meta: meta}), 462 | do: Serializer.serialize(__MODULE__, data, conn, meta) 463 | 464 | def render("delete.json", %{data: data, conn: conn}), 465 | do: Serializer.serialize(__MODULE__, data, conn) 466 | else 467 | raise ArgumentError, 468 | "Attempted to call function that depends on Phoenix. " <> 469 | "Make sure Phoenix is part of your dependencies" 470 | end 471 | end 472 | end 473 | 474 | @spec url_for(t(), term(), Conn.t() | nil) :: String.t() 475 | def url_for(view, data, nil = _conn) when is_nil(data) or is_list(data), 476 | do: URI.to_string(%URI{path: Enum.join([view.namespace(), path_for(view, data)], "/")}) 477 | 478 | def url_for(view, data, nil = _conn) do 479 | URI.to_string(%URI{ 480 | path: Enum.join([view.namespace(), path_for(view, data), view.id(data)], "/") 481 | }) 482 | end 483 | 484 | def url_for(view, data, %Plug.Conn{} = conn) when is_nil(data) or is_list(data) do 485 | URI.to_string(%URI{ 486 | scheme: scheme(conn), 487 | host: host(conn), 488 | port: port(conn), 489 | path: Enum.join([view.namespace(), path_for(view, data)], "/") 490 | }) 491 | end 492 | 493 | def url_for(view, data, %Plug.Conn{} = conn) do 494 | URI.to_string(%URI{ 495 | scheme: scheme(conn), 496 | host: host(conn), 497 | port: port(conn), 498 | path: Enum.join([view.namespace(), path_for(view, data), view.id(data)], "/") 499 | }) 500 | end 501 | 502 | @spec url_for_rel(t(), data(), resource_type(), Conn.t() | nil) :: String.t() 503 | def url_for_rel(view, data, rel_type, conn) do 504 | "#{url_for(view, data, conn)}/relationships/#{rel_type}" 505 | end 506 | 507 | @spec url_for_rel(t(), data(), Conn.query_params(), Paginator.params()) :: String.t() 508 | def url_for_pagination( 509 | view, 510 | data, 511 | %{query_params: query_params} = conn, 512 | nil = _pagination_params 513 | ) do 514 | query = 515 | query_params 516 | |> Utils.List.to_list_of_query_string_components() 517 | |> URI.encode_query() 518 | 519 | prepare_url(view, query, data, conn) 520 | end 521 | 522 | def url_for_pagination(view, data, %{query_params: query_params} = conn, pagination_params) do 523 | query_params = Map.put(query_params, "page", pagination_params) 524 | 525 | url_for_pagination(view, data, %{conn | query_params: query_params}, nil) 526 | end 527 | 528 | @spec visible_fields(t(), data(), Conn.t() | nil) :: list(atom) 529 | def visible_fields(view, data, conn) do 530 | all_fields = 531 | view 532 | |> requested_fields_for_type(data, conn) 533 | |> net_fields_for_type(view.resource_fields(data)) 534 | 535 | hidden_fields = view.hidden(data) 536 | 537 | all_fields -- hidden_fields 538 | end 539 | 540 | defp net_fields_for_type(requested_fields, fields) when requested_fields in [nil, %{}], 541 | do: fields 542 | 543 | defp net_fields_for_type(requested_fields, fields) do 544 | fields 545 | |> MapSet.new() 546 | |> MapSet.intersection(MapSet.new(requested_fields)) 547 | |> MapSet.to_list() 548 | end 549 | 550 | defp prepare_url(view, "", data, conn), do: url_for(view, data, conn) 551 | 552 | defp prepare_url(view, query, data, conn) do 553 | view 554 | |> url_for(data, conn) 555 | |> URI.parse() 556 | |> struct(query: query) 557 | |> URI.to_string() 558 | end 559 | 560 | defp requested_fields_for_type(view, data, %Conn{assigns: %{jsonapi_query: %{fields: fields}}}) do 561 | fields[view.resource_type(data)] 562 | end 563 | 564 | defp requested_fields_for_type(_view, _data, _conn), do: nil 565 | 566 | defp host(%Conn{host: host}), 567 | do: Application.get_env(:jsonapi, :host, host) 568 | 569 | defp port(%Conn{port: 0} = conn), 570 | do: port(%{conn | port: conn |> scheme() |> URI.default_port()}) 571 | 572 | defp port(%Conn{port: port}), 573 | do: Application.get_env(:jsonapi, :port, port) 574 | 575 | defp scheme(%Conn{scheme: scheme}), 576 | do: Application.get_env(:jsonapi, :scheme, to_string(scheme)) 577 | 578 | defp path_for(view, data), do: view.path() || view.resource_type(data) 579 | end 580 | --------------------------------------------------------------------------------