Skip to content

Commit

Permalink
Merge branch 'main' into nested-filters
Browse files Browse the repository at this point in the history
  • Loading branch information
TylerPachal authored Aug 2, 2023
2 parents 537f447 + 828284e commit 8cad4ab
Show file tree
Hide file tree
Showing 13 changed files with 544 additions and 23 deletions.
19 changes: 11 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,41 @@ jobs:
name: Test (OTP ${{ matrix.otp }} / Elixir ${{ matrix.elixir }})
strategy:
matrix:
elixir: ["1.13", "1.12", "1.11", "1.10"]
elixir: ["1.14", "1.13", "1.12", "1.11", "1.10"]
# All of the above can use this version. For details see: https://hexdocs.pm/elixir/compatibility-and-deprecations.html#compatibility-between-elixir-and-erlang-otp
otp: [25, 24, 23, 22]
exclude:
- { otp: "24", elixir: "1.10" }
- { otp: "25", elixir: "1.10" }
- { otp: "25", elixir: "1.11" }
- { otp: "25", elixir: "1.12" }
- { otp: "22", elixir: "1.14" }
steps:
- uses: actions/checkout@v2
- uses: erlef/setup-beam@v1
- uses: actions/checkout@v3
- id: beam
uses: erlef/setup-beam@v1
with:
otp-version: ${{ matrix.otp }}
elixir-version: ${{ matrix.elixir }}
- run: mix deps.get
- run: mix test

lint:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
name: Linting
strategy:
matrix:
elixir: ["1.13"]
otp: [25]
steps:
- uses: actions/checkout@v2
- uses: erlef/setup-beam@v1
- uses: actions/checkout@v3
- id: beam
uses: erlef/setup-beam@v1
with:
otp-version: ${{ matrix.otp }}
elixir-version: ${{ matrix.elixir }}
- name: PLT cache
uses: actions/cache@v2
uses: actions/cache@v3
with:
key: |
${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt
Expand All @@ -58,4 +61,4 @@ jobs:
- run: mix compile --warnings-as-errors
- run: mix format --check-formatted
- run: mix credo --strict
- run: mix dialyzer --halt-exit-status
- run: mix dialyzer
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
env:
HEX_API_KEY: ${{ secrets.HEXPM_SECRET }}
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- uses: erlef/setup-beam@v1
with:
otp-version: 25
Expand Down
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
# Changelog

## NEXT
...

## 1.6.2 (2023-07-03)

### What's Changed
* Error handling fixed per https://github.com/beam-community/jsonapi/issues/294.

**Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.6.1...1.6.2

## 1.6.1 (2023-06-26)

### What's Changed
The features of #270 were broken in two ways that this release fixes.

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

**Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.6.0...1.6.1

## 1.6.0 (2023-06-12)

### What's Changed
* Add support for a JSON:API includes allowlist. by @mattpolzin in https://github.com/beam-community/jsonapi/pull/292

**Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.5.1...1.6.0

## 1.5.1 (2023-05-19)

### What's Changed
* Change camelize behavior by @TylerPachal in https://github.com/beam-community/jsonapi/pull/293

Specifically, already-camilized strings will no longer be turned to all-lowercase by the `:camelize` transformation; they will be left alone.

**Full Changelog**: https://github.com/beam-community/jsonapi/compare/1.5.0...1.5.1

## 1.5.0 (2023-01-25)

### What's Changed
Expand Down
66 changes: 62 additions & 4 deletions lib/jsonapi/error_view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ defmodule JSONAPI.ErrorView do
import Plug.Conn, only: [send_resp: 3, halt: 1, put_resp_content_type: 2]

@crud_message "Check out http://jsonapi.org/format/#crud for more info."
@relationship_resource_linkage_message "Check out https://jsonapi.org/format/#document-resource-object-linkage for more info."

@spec build_error(binary(), pos_integer(), binary() | nil, binary() | nil) :: map()
@type error_attrs :: map()

@spec build_error(binary(), pos_integer(), binary() | nil, binary() | nil) :: error_attrs()
def build_error(title, status, detail, pointer \\ nil, meta \\ nil) do
error = %{
detail: detail,
Expand Down Expand Up @@ -93,12 +96,57 @@ defmodule JSONAPI.ErrorView do
|> serialize_error
end

@spec relationships_missing_object :: map()
def relationships_missing_object do
"Relationships parameter is not an object"
|> build_error(
400,
"Check out https://jsonapi.org/format/#document-resource-object-relationships for more info.",
"/data/relationships"
)
|> serialize_error
end

@spec missing_relationship_data_param_error_attrs(binary()) :: error_attrs()
def missing_relationship_data_param_error_attrs(relationship_name) do
"Missing data member in relationship"
|> build_error(
400,
"Check out https://jsonapi.org/format/#crud-creating and https://jsonapi.org/format/#crud-updating-resource-relationships for more info.",
"/data/relationships/#{relationship_name}/data"
)
end

@spec missing_relationship_data_id_param_error_attrs(binary()) :: error_attrs()
def missing_relationship_data_id_param_error_attrs(relationship_name) do
"Missing id in relationship data parameter"
|> build_error(
400,
@relationship_resource_linkage_message,
"/data/relationships/#{relationship_name}/data/id"
)
end

@spec missing_relationship_data_type_param_error_attrs(binary()) :: error_attrs()
def missing_relationship_data_type_param_error_attrs(relationship_name) do
"Missing type in relationship data parameter"
|> build_error(
400,
@relationship_resource_linkage_message,
"/data/relationships/#{relationship_name}/data/type"
)
end

@spec send_error(Plug.Conn.t(), term()) :: term()
def send_error(conn, %{errors: [%{status: status}]} = error),
do: send_error(conn, status, error)

def send_error(conn, %{errors: errors} = error) when is_list(errors) do
status = Enum.max_by(errors, &Map.get(&1, :status))
status =
errors
|> Enum.max_by(&Map.get(&1, :status))
|> Map.get(:status)

send_error(conn, status, error)
end

Expand All @@ -120,12 +168,22 @@ defmodule JSONAPI.ErrorView do
|> halt
end

@spec serialize_error(map()) :: map()
@spec serialize_error(error_attrs()) :: map()
def serialize_error(error) do
error = Map.take(error, [:detail, :id, :links, :meta, :source, :status, :title])
error = extract_error(error)
%{errors: [error]}
end

@spec serialize_errors(list()) :: map()
def serialize_errors(errors) do
extracted = Enum.map(errors, &extract_error/1)
%{errors: extracted}
end

defp extract_error(error) do
Map.take(error, [:detail, :id, :links, :meta, :source, :status, :title])
end

defp append_field(error, _field, nil), do: error
defp append_field(error, :meta, value), do: Map.put(error, :meta, %{meta: value})
defp append_field(error, :source, value), do: Map.put(error, :source, %{pointer: value})
Expand Down
54 changes: 54 additions & 0 deletions lib/jsonapi/plugs/format_required.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,60 @@ defmodule JSONAPI.FormatRequired do

def call(%{method: method} = conn, _opts) when method in ~w[DELETE GET HEAD], do: conn

def call(
%{method: method, params: %{"data" => %{"type" => _, "relationships" => relationships}}} =
conn,
_
)
when method in ~w[POST PATCH] and not is_map(relationships) do
send_error(conn, relationships_missing_object())
end

def call(
%{
method: method,
params: %{"data" => %{"type" => _, "relationships" => relationships}}
} = conn,
_
)
when method in ~w[POST PATCH] and is_map(relationships) do
errors =
Enum.reduce(relationships, [], fn
{_relationship_name, %{"data" => %{"type" => _type, "id" => _}}}, acc ->
acc

{relationship_name, %{"data" => %{"type" => _type}}}, acc ->
error = missing_relationship_data_id_param_error_attrs(relationship_name)
[error | acc]

{relationship_name, %{"data" => %{"id" => _type}}}, acc ->
error = missing_relationship_data_type_param_error_attrs(relationship_name)
[error | acc]

{relationship_name, %{"data" => %{}}}, acc ->
id_error = missing_relationship_data_id_param_error_attrs(relationship_name)
type_error = missing_relationship_data_type_param_error_attrs(relationship_name)
[id_error | [type_error | acc]]

{_relationship_name, %{"data" => _}}, acc ->
# Allow things other than resource identifier objects per https://jsonapi.org/format/#document-resource-object-linkage
# - null for empty to-one relationships.
# - an empty array ([]) for empty to-many relationships.
# - an array of resource identifier objects for non-empty to-many relationships.
acc

{relationship_name, _}, acc ->
error = missing_relationship_data_param_error_attrs(relationship_name)
[error | acc]
end)

if Enum.empty?(errors) do
conn
else
send_error(conn, serialize_errors(errors))
end
end

def call(%{method: "POST", params: %{"data" => %{"type" => _}}} = conn, _), do: conn

def call(%{method: method, params: %{"data" => [%{"type" => _} | _]}} = conn, _)
Expand Down
23 changes: 23 additions & 0 deletions lib/jsonapi/plugs/query_parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,14 @@ defmodule JSONAPI.QueryParser do
plug JSONAPI.QueryParser,
filter: ~w(title),
sort: ~w(created_at title),
include: ~w(others) # optionally specify a list of allowed includes.
view: MyView
```
If you specify which includes are allowed, any include name not in the list
will produce an error. If you omit the `include` list then all relationships
specified by the given resource will be allowed.
If your controller's index function receives a query with params inside those
bounds it will build a `JSONAPI.Config` that has all the validated and parsed
fields for your usage. The final configuration will be added to assigns
Expand Down Expand Up @@ -206,6 +211,8 @@ defmodule JSONAPI.QueryParser do
|> Enum.map(&underscore/1)

Enum.reduce(includes, [], fn inc, acc ->
check_include_validity!(inc, config)

if inc =~ ~r/\w+\.\w+/ do
acc ++ handle_nested_include(inc, valid_includes, config)
else
Expand All @@ -225,6 +232,22 @@ defmodule JSONAPI.QueryParser do
end)
end

defp check_include_validity!(key, %Config{opts: opts, view: view}) do
if opts do
check_include_validity!(key, Keyword.get(opts, :include), view)
end
end

defp check_include_validity!(key, allowed_includes, view) when is_list(allowed_includes) do
unless key in allowed_includes do
raise_invalid_include_query(key, view.type())
end
end

defp check_include_validity!(_key, nil, _view) do
# all includes are allowed if none are specified in input config
end

@spec handle_nested_include(key :: String.t(), valid_include :: list(), config :: Config.t()) ::
list() | no_return()
def handle_nested_include(key, valid_include, config) do
Expand Down
2 changes: 1 addition & 1 deletion lib/jsonapi/serializer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ defmodule JSONAPI.Serializer do
@spec encode_relationships(Conn.t(), document(), tuple(), list()) :: tuple()
def encode_relationships(conn, doc, {view, data, _, _} = view_info, options) do
view.relationships()
|> Enum.filter(&data_loaded?(Map.get(data, get_data_key(&1))))
|> Enum.filter(&assoc_loaded?(Map.get(data, get_data_key(&1))))
|> Enum.map_reduce(doc, &build_relationships(conn, view_info, &1, &2, options))
end

Expand Down
20 changes: 15 additions & 5 deletions lib/jsonapi/utils/string.ex
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ defmodule JSONAPI.Utils.String do
iex> camelize("")
""
iex> camelize("alreadyCamelized")
"alreadyCamelized"
"""
@spec camelize(atom) :: String.t()
def camelize(value) when is_atom(value) do
Expand All @@ -109,12 +112,19 @@ defmodule JSONAPI.Utils.String do
with words <-
Regex.split(
~r{(?<=[a-zA-Z0-9])[-_](?=[a-zA-Z0-9])},
to_string(value)
to_string(value),
trim: true
) do
[h | t] = words |> Enum.filter(&(&1 != ""))

[String.downcase(h) | camelize_list(t)]
|> Enum.join()
case words do
# If there is only one word, leave it as-is
[word] ->
word

# If there are multiple words, perform the camelizing
[h | t] ->
[String.downcase(h) | camelize_list(t)]
|> Enum.join()
end
end
end

Expand Down
4 changes: 3 additions & 1 deletion lib/jsonapi/view.ex
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,9 @@ defmodule JSONAPI.View do
@callback pagination_links(data(), Conn.t(), Paginator.page(), Paginator.options()) ::
Paginator.links()
@callback path() :: String.t() | nil
@callback relationships() :: [{atom(), t() | {t(), :include}}]
@callback relationships() :: [
{atom(), t() | {t(), :include} | {atom(), t()} | {atom(), t(), :include}}
]
@callback type() :: resource_type()
@callback url_for(data(), Conn.t() | nil) :: String.t()
@callback url_for_pagination(data(), Conn.t(), Paginator.params()) :: String.t()
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule JSONAPI.Mixfile do
def project do
[
app: :jsonapi,
version: "1.5.0",
version: "1.6.2",
package: package(),
compilers: compilers(Mix.env()),
description: description(),
Expand Down
Loading

0 comments on commit 8cad4ab

Please sign in to comment.