├── documentation ├── .git_keep └── .git_keep.license ├── .tool-versions ├── logos ├── logo-only.png ├── small-logo.png ├── logo-black-text.png ├── logo-white-text.png ├── cropped-for-header.png ├── logo-only.png.license ├── small-logo.png.license ├── logo-black-text.png.license ├── logo-white-text.png.license └── cropped-for-header.png.license ├── mix.lock.license ├── .tool-versions.license ├── test ├── support │ ├── cybrid.json.license │ ├── pet_store.json.license │ └── pet_store.json ├── test_helper.exs ├── open_api_petstore_test.exs ├── petstore_test.exs ├── open_api_cybrid_test.exs ├── hackernews_test.exs └── custom_pagination_test.exs ├── lib ├── default_tesla.ex ├── paginator │ ├── builtins.ex │ ├── paginator.ex │ └── continuation_property.ex ├── helpers.ex ├── errors │ └── invalid_data.ex ├── data_layer │ ├── transformers │ │ └── set_endpoint_defaults.ex │ ├── info.ex │ └── data_layer.ex ├── field.ex ├── ash_json_api_wrapper.ex ├── endpoint.ex ├── open_api │ └── resource_generator.ex └── filter.ex ├── .github ├── dependabot.yml └── workflows │ └── elixir.yml ├── config └── config.exs ├── .formatter.exs ├── .gitignore ├── .check.exs ├── LICENSES └── MIT.txt ├── README.md ├── mix.exs ├── .credo.exs └── mix.lock /documentation/.git_keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 26.0.2 2 | elixir 1.18.1 3 | pipx 1.8.0 4 | -------------------------------------------------------------------------------- /logos/logo-only.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/ash_json_api_wrapper/HEAD/logos/logo-only.png -------------------------------------------------------------------------------- /logos/small-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/ash_json_api_wrapper/HEAD/logos/small-logo.png -------------------------------------------------------------------------------- /logos/logo-black-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/ash_json_api_wrapper/HEAD/logos/logo-black-text.png -------------------------------------------------------------------------------- /logos/logo-white-text.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/ash_json_api_wrapper/HEAD/logos/logo-white-text.png -------------------------------------------------------------------------------- /logos/cropped-for-header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/ash_json_api_wrapper/HEAD/logos/cropped-for-header.png -------------------------------------------------------------------------------- /mix.lock.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /.tool-versions.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/logo-only.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/small-logo.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /documentation/.git_keep.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/logo-black-text.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/logo-white-text.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /test/support/cybrid.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/cropped-for-header.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /test/support/pet_store.json.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | ExUnit.start() 6 | Mox.defmock(AshJsonApiWrapper.MockAdapter, for: Tesla.Adapter) 7 | ExUnit.configure(exclude: [:hackernews]) 8 | -------------------------------------------------------------------------------- /lib/default_tesla.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.DefaultTesla do 6 | @moduledoc """ 7 | A bare bones tesla implementation used by default if one is not provided. 8 | """ 9 | 10 | use Tesla 11 | 12 | plug(Tesla.Middleware.FollowRedirects) 13 | end 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | version: 2 6 | updates: 7 | - package-ecosystem: mix 8 | directory: "/" 9 | schedule: 10 | interval: weekly 11 | day: thursday 12 | groups: 13 | production-dependencies: 14 | dependency-type: production 15 | dev-dependencies: 16 | dependency-type: development 17 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: CI 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | branches: [main] 11 | pull_request: 12 | branches: [main] 13 | workflow_call: 14 | jobs: 15 | ash-ci: 16 | uses: ash-project/ash/.github/workflows/ash-ci.yml@main 17 | with: 18 | reuse: true 19 | secrets: 20 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 21 | -------------------------------------------------------------------------------- /lib/paginator/builtins.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.Paginator.Builtins do 6 | @moduledoc "Builtin paginators" 7 | 8 | @spec continuation_property(String.t(), opts :: Keyword.t()) :: 9 | AshJsonApiWrapper.Paginator.ref() 10 | def continuation_property(get, opts) do 11 | {AshJsonApiWrapper.Paginator.ContinuationProperty, Keyword.put(opts, :get, get)} 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/helpers.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.Helpers do 6 | @moduledoc false 7 | def put_at_path(_, [], value), do: value 8 | 9 | def put_at_path(nil, [key | rest], value) do 10 | %{key => put_at_path(nil, rest, value)} 11 | end 12 | 13 | def put_at_path(map, [key | rest], value) when is_map(map) do 14 | map 15 | |> Map.put_new(key, %{}) 16 | |> Map.update!(key, &put_at_path(&1, rest, value)) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/errors/invalid_data.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.Errors.InvalidData do 6 | @moduledoc "Used when an invalid value is present in the response for a given attribute" 7 | 8 | use Splode.Error, fields: [:field, :value], class: :invalid 9 | 10 | def message(error) do 11 | "Invalid value provided#{for_field(error)}: #{inspect(error.value)}" 12 | end 13 | 14 | defp for_field(%{field: field}) when not is_nil(field), do: " for #{field}" 15 | defp for_field(_), do: "" 16 | end 17 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import Config 6 | 7 | if Mix.env() == :dev do 8 | config :git_ops, 9 | mix_project: AshJsonApiWrapper.MixProject, 10 | changelog_file: "CHANGELOG.md", 11 | repository_url: "https://github.com/ash-project/ash_json_api_wrapper", 12 | # Instructs the tool to manage your mix version in your `mix.exs` file 13 | # See below for more information 14 | manage_mix_version?: true, 15 | # Instructs the tool to manage the version in your README.md 16 | # Pass in `true` to use `"README.md"` or a string to customize 17 | manage_readme_version: [ 18 | "README.md" 19 | ], 20 | version_tag_prefix: "v" 21 | end 22 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | spark_locals_without_parens = [ 6 | base: 1, 7 | base_entity_path: 1, 8 | base_paginator: 1, 9 | endpoint: 1, 10 | endpoint: 2, 11 | entity_path: 1, 12 | field: 1, 13 | field: 2, 14 | fields_in: 1, 15 | filter_handler: 1, 16 | get_endpoint: 2, 17 | get_endpoint: 3, 18 | limit_with: 1, 19 | paginator: 1, 20 | path: 1, 21 | runtime_sort?: 1, 22 | tesla: 1, 23 | write_entity_path: 1, 24 | write_path: 1 25 | ] 26 | 27 | [ 28 | import_deps: [:ash], 29 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 30 | locals_without_parens: spark_locals_without_parens, 31 | export: [ 32 | locals_without_parens: spark_locals_without_parens 33 | ] 34 | ] 35 | -------------------------------------------------------------------------------- /lib/paginator/paginator.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.Paginator do 6 | @moduledoc """ 7 | Behavior for scanning pages of a paginated endpoint. 8 | """ 9 | 10 | @type ref :: {module, Keyword.t()} 11 | 12 | defmacro __using__(_) do 13 | quote do 14 | @behaviour AshJsonApiWrapper.Paginator 15 | end 16 | end 17 | 18 | @callback start(opts :: Keyword.t()) :: 19 | {:ok, %{optional(:params) => map, optional(:headers) => map}} 20 | 21 | @callback continue( 22 | response :: term, 23 | entities :: [Ash.Resource.record()], 24 | opts :: Keyword.t() 25 | ) :: {:ok, %{optional(:params) => map, optional(:headers) => map}} | :halt 26 | end 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # The directory Mix will write compiled artifacts to. 6 | /_build/ 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | /cover/ 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | /deps/ 13 | 14 | # Where third-party dependencies like ExDoc output generated docs. 15 | /doc/ 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | /.fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | *.ez 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | ash_json_api_wrapper-*.tar 28 | 29 | # Temporary files, for example, from tests. 30 | /tmp/ 31 | 32 | # OS X folder metadata 33 | .DS_Store 34 | 35 | # VSCode settings 36 | .vscode/* 37 | -------------------------------------------------------------------------------- /lib/paginator/continuation_property.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.Paginator.ContinuationProperty do 6 | @moduledoc "A paginator that uses a continuation property to paginate" 7 | use AshJsonApiWrapper.Paginator 8 | 9 | def start(_opts) do 10 | {:ok, %{}} 11 | end 12 | 13 | def continue(_response, [], _), do: :halt 14 | 15 | def continue(response, _entities, opts) do 16 | case ExJSONPath.eval(response, opts[:get]) do 17 | {:ok, [value | _]} when not is_nil(value) -> 18 | if opts[:header] do 19 | {:ok, %{headers: %{opts[:header] => value}}} 20 | else 21 | if opts[:param] do 22 | {:ok, 23 | %{params: AshJsonApiWrapper.Helpers.put_at_path(%{}, List.wrap(opts[:param]), value)}} 24 | else 25 | :halt 26 | end 27 | end 28 | 29 | _ -> 30 | :halt 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /.check.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | [ 6 | ## all available options with default values (see `mix check` docs for description) 7 | # parallel: true, 8 | # skipped: true, 9 | 10 | ## list of tools (see `mix check` docs for defaults) 11 | tools: [ 12 | ## curated tools may be disabled (e.g. the check for compilation warnings) 13 | # {:compiler, false}, 14 | 15 | ## ...or adjusted (e.g. use one-line formatter for more compact credo output) 16 | # {:credo, "mix credo --format oneline"}, 17 | 18 | {:check_formatter, command: "mix spark.formatter --check"}, 19 | {:reuse, command: ["pipx", "run", "reuse", "lint", "-q"]} 20 | 21 | ## custom new tools may be added (mix tasks or arbitrary commands) 22 | # {:my_mix_task, command: "mix release", env: %{"MIX_ENV" => "prod"}}, 23 | # {:my_arbitrary_tool, command: "npm test", cd: "assets"}, 24 | # {:my_arbitrary_script, command: ["my_script", "argument with spaces"], cd: "scripts"} 25 | ] 26 | ] 27 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 16 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 18 | USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ![Elixir CI](https://github.com/ash-project/ash_json_api_wrapper/workflows/CI/badge.svg) 8 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 9 | [![Hex version badge](https://img.shields.io/hexpm/v/ash_json_api_wrapper.svg)](https://hex.pm/packages/ash_json_api_wrapper) 10 | [![Hexdocs badge](https://img.shields.io/badge/docs-hexdocs-purple)](https://hexdocs.pm/ash_json_api_wrapper) 11 | [![REUSE status](https://api.reuse.software/badge/github.com/ash-project/ash_json_api_wrapper)](https://api.reuse.software/info/github.com/ash-project/ash_json_api_wrapper) 12 | 13 | # AshJsonApiWrapper 14 | 15 | **TODO: Add description** 16 | 17 | ## Installation 18 | 19 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 20 | by adding `ash_json_api_wrapper` to your list of dependencies in `mix.exs`: 21 | 22 | ```elixir 23 | def deps do 24 | [ 25 | {:ash_json_api_wrapper, "~> 0.1.0"} 26 | ] 27 | end 28 | ``` 29 | 30 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 31 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 32 | be found at [https://hexdocs.pm/ash_json_api_wrapper](https://hexdocs.pm/ash_json_api_wrapper). 33 | 34 | -------------------------------------------------------------------------------- /lib/data_layer/transformers/set_endpoint_defaults.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.DataLayer.Transformers.SetEndpointDefaults do 6 | @moduledoc false 7 | use Spark.Dsl.Transformer 8 | 9 | alias Spark.Dsl.Transformer 10 | 11 | @impl Spark.Dsl.Transformer 12 | def transform(dsl) do 13 | base_entity_path = AshJsonApiWrapper.DataLayer.Info.base_entity_path(dsl) 14 | base_paginator = AshJsonApiWrapper.DataLayer.Info.base_paginator(dsl) 15 | base_fields = AshJsonApiWrapper.DataLayer.Info.fields(dsl) 16 | 17 | dsl 18 | |> AshJsonApiWrapper.DataLayer.Info.endpoints() 19 | |> Enum.reduce({:ok, dsl}, fn endpoint, {:ok, dsl} -> 20 | endpoint_field_names = Enum.map(endpoint.fields, & &1.name) 21 | 22 | {:ok, 23 | Transformer.replace_entity( 24 | dsl, 25 | [:ash_json_api_wrapper, :endpoint], 26 | %{ 27 | endpoint 28 | | entity_path: endpoint.entity_path || base_entity_path, 29 | paginator: endpoint.paginator || base_paginator, 30 | fields: 31 | Enum.reject(base_fields, &(&1.name in endpoint_field_names)) ++ endpoint.fields 32 | } 33 | )} 34 | end) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/open_api_petstore_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.OpenApi.PetstoreTest do 6 | use ExUnit.Case 7 | require Ash.Query 8 | @moduletag :oapi_petstore 9 | 10 | @json "test/support/pet_store.json" |> File.read!() |> Jason.decode!() 11 | 12 | defmodule TestingTesla do 13 | use Tesla 14 | 15 | # plug(Tesla.Middleware.Headers, [ 16 | # {"authorization", "Bearer xxx"} 17 | # ]) 18 | end 19 | 20 | @config [ 21 | tesla: TestingTesla, 22 | endpoint: "https://petstore3.swagger.io/api/v3", 23 | resources: [ 24 | Petstore: [ 25 | path: "/store/order/{orderId}", 26 | object_type: "components.schemas.Order", 27 | primary_key: "id", 28 | # entity_path: "", 29 | fields: [ 30 | orderId: [ 31 | filter_handler: {:place_in_csv_list, ["id"]} 32 | ] 33 | ] 34 | ] 35 | ] 36 | ] 37 | 38 | defmodule Domain do 39 | use Ash.Domain, 40 | validate_config_inclusion?: false 41 | 42 | resources do 43 | allow_unregistered? true 44 | end 45 | end 46 | 47 | test "it does stuff" do 48 | @json 49 | |> AshJsonApiWrapper.OpenApi.ResourceGenerator.generate(Domain, @config) 50 | |> Enum.map(fn {resource, code} -> 51 | Code.eval_string(code) 52 | resource 53 | end) 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/field.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.Field do 6 | @moduledoc "Represents a field mapped in the target api." 7 | defstruct [:name, :path, :write_path, :filter_handler] 8 | 9 | @type t :: %__MODULE__{} 10 | 11 | def schema do 12 | [ 13 | name: [ 14 | type: :atom, 15 | required: true, 16 | doc: "The attribute this field is configuring" 17 | ], 18 | path: [ 19 | type: :string, 20 | doc: "The path of the value for this field, relative to the entity's path" 21 | ], 22 | write_path: [ 23 | type: {:list, :string}, 24 | doc: "The list path of the value for this field when writing." 25 | ], 26 | filter_handler: [ 27 | type: :any, 28 | doc: """ 29 | Specification for how the field is handled when used in filters. This is relatively limited at the moment. 30 | 31 | Supports the following: 32 | * `:simple` - Sets the value directly into the query params. 33 | * `{:simple, "key" | ["path", "to", "key"]}` - Sets the value directly into the query params using the provided key. 34 | * `{:place_in_list, ["path", "to", "list"]}` - Supports `or equals` and `in` filters over the given field, by placing their values in the provided list. 35 | * `{:place_in_csv_list, ["path", "to", "list"]}` - Supports `or equals` and `in` filters over the given field, by placing their values in the provided list. 36 | """ 37 | ] 38 | ] 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/ash_json_api_wrapper.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper do 6 | @moduledoc """ 7 | Functions for interacting with AshJsonApiWrapper changesets and queries. 8 | """ 9 | 10 | @spec set_body_param(query_or_changeset, String.t(), any) :: query_or_changeset 11 | when query_or_changeset: Ash.Query.t() | Ash.Changeset.t() 12 | def set_body_param(query, key, value) do 13 | new_context = 14 | query.context 15 | |> Map.put_new(:data_layer, %{}) 16 | |> Map.update!(:data_layer, fn data_layer -> 17 | data_layer 18 | |> Map.put_new(:body, %{}) 19 | |> Map.update!(:body, &Map.put(&1, key, value)) 20 | end) 21 | 22 | %{query | context: new_context} 23 | end 24 | 25 | @spec merge_query_params(query_or_changeset, map) :: query_or_changeset 26 | when query_or_changeset: Ash.Query.t() | Ash.Changeset.t() 27 | def merge_query_params(%Ash.Query{} = query, params) do 28 | Ash.Query.set_context(query, %{data_layer: %{query_params: params}}) 29 | end 30 | 31 | def merge_query_params(%Ash.Changeset{} = changeset, params) do 32 | Ash.Changeset.set_context(changeset, %{data_layer: %{query_params: params}}) 33 | end 34 | 35 | @spec set_query_params(query_or_changeset, map) :: query_or_changeset 36 | when query_or_changeset: Ash.Query.t() | Ash.Changeset.t() 37 | def set_query_params(query, params) do 38 | new_context = 39 | query.context 40 | |> Map.put_new(:data_layer, %{}) 41 | |> Map.update!(:data_layer, fn data_layer -> 42 | Map.put(data_layer, :query_params, params) 43 | end) 44 | 45 | %{query | context: new_context} 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/petstore_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.Petstore.Test do 6 | use ExUnit.Case 7 | require Ash.Query 8 | @moduletag :petstore 9 | 10 | defmodule TestingTesla do 11 | use Tesla 12 | # plug Tesla.Middleware.Logger 13 | end 14 | 15 | defmodule Petstore do 16 | use Ash.Resource, 17 | data_layer: AshJsonApiWrapper.DataLayer, 18 | domain: AshJsonApiWrapper.Petstore.Test.Domain, 19 | validate_domain_inclusion?: false 20 | 21 | json_api_wrapper do 22 | tesla(TestingTesla) 23 | 24 | endpoints do 25 | base("https://petstore3.swagger.io/api/v3") 26 | 27 | endpoint [:find_pets_by_status, :by_status] do 28 | path("/pet/findByStatus") 29 | 30 | field :status do 31 | filter_handler(:simple) 32 | end 33 | end 34 | 35 | get_endpoint :pet, :id do 36 | path("/pet/:id") 37 | end 38 | end 39 | 40 | fields do 41 | end 42 | end 43 | 44 | actions do 45 | read(:find_pets_by_status) do 46 | primary? false 47 | end 48 | 49 | read(:by_status) do 50 | primary? true 51 | end 52 | 53 | read(:pet) do 54 | primary? false 55 | end 56 | end 57 | 58 | attributes do 59 | attribute :id, :integer do 60 | primary_key?(true) 61 | allow_nil?(false) 62 | end 63 | 64 | # attribute(:category, :string) 65 | attribute(:name, :string) 66 | attribute(:photo_urls, :string) 67 | 68 | attribute :status, :atom do 69 | constraints(one_of: [:available, :pending, :sold]) 70 | end 71 | 72 | # attribute(:tags, :string) 73 | end 74 | end 75 | 76 | defmodule Domain do 77 | use Ash.Domain, validate_config_inclusion?: false 78 | 79 | resources do 80 | allow_unregistered?(true) 81 | end 82 | end 83 | 84 | # test "it works" do 85 | # Petstore 86 | # |> Ash.Query.for_read(:find_pets_by_status) 87 | # |> Ash.Query.filter(status == "pending") 88 | # |> Ash.read!() 89 | # 90 | # Petstore 91 | # |> Ash.Query.for_read(:by_status) 92 | # |> Ash.Query.filter(status == "available") 93 | # |> Ash.read!() 94 | # 95 | # Petstore 96 | # |> Ash.Query.for_read(:pet) 97 | # |> Ash.Query.filter(id == 10) 98 | # |> Ash.read!() 99 | # end 100 | end 101 | -------------------------------------------------------------------------------- /lib/endpoint.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.Endpoint do 6 | defstruct [ 7 | :action, 8 | :path, 9 | :entity_path, 10 | :fields, 11 | :fields_in, 12 | :write_entity_path, 13 | :get_for, 14 | :runtime_sort?, 15 | :limit_with, 16 | :paginator, 17 | :__identifier__ 18 | ] 19 | 20 | @type t :: %__MODULE__{} 21 | 22 | def schema do 23 | [ 24 | action: [ 25 | type: {:wrap_list, :atom}, 26 | required: true, 27 | doc: "The action this path is for" 28 | ], 29 | path: [ 30 | type: :string, 31 | default: "/", 32 | doc: "The path of the endpoint relative to the base, or an absolute path" 33 | ], 34 | limit_with: [ 35 | type: :any, 36 | doc: "To provide query limits as endpoint query params, use `{:param, \"param_name\"}`" 37 | ], 38 | fields_in: [ 39 | type: {:in, [:body, :params]}, 40 | default: :body, 41 | doc: "Where to place the fields when writing them." 42 | ], 43 | write_entity_path: [ 44 | type: {:list, :string}, 45 | doc: 46 | "The list path at which the entity should be placed in the body when creating/updating." 47 | ], 48 | entity_path: [ 49 | type: :string, 50 | doc: "A json path at which the entities can be read back from the response" 51 | ], 52 | runtime_sort?: [ 53 | type: :boolean, 54 | default: false, 55 | doc: 56 | "Whether or not this endpoint should support sorting at runtime after the data has been received." 57 | ], 58 | paginator: [ 59 | type: 60 | {:spark_behaviour, AshJsonApiWrapper.Paginator, AshJsonApiWrapper.Paginator.Builtins}, 61 | doc: 62 | "A module implementing the `AshJSonApiWrapper.Paginator` behaviour, to allow scanning pages when reading." 63 | ] 64 | ] 65 | end 66 | 67 | def get_schema do 68 | Keyword.merge( 69 | schema(), 70 | get_for: [ 71 | type: :atom, 72 | doc: """ 73 | Signifies that this endpoint is a get endpoint for a given field. 74 | 75 | See the docs of `get_endpoint` for more. 76 | """ 77 | ] 78 | ) 79 | end 80 | 81 | def default(resource) do 82 | %__MODULE__{ 83 | path: AshJsonApiWrapper.DataLayer.Info.endpoint_base(resource), 84 | entity_path: AshJsonApiWrapper.DataLayer.Info.base_entity_path(resource), 85 | paginator: AshJsonApiWrapper.DataLayer.Info.base_paginator(resource), 86 | fields: AshJsonApiWrapper.DataLayer.Info.fields(resource), 87 | fields_in: :body 88 | } 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/data_layer/info.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.DataLayer.Info do 6 | @moduledoc "Introspection helpers for AshJsonApiWrapper.DataLayer" 7 | 8 | alias Spark.Dsl.Extension 9 | 10 | @spec endpoint_base(map | Ash.Resource.t()) :: String.t() | nil 11 | def endpoint_base(resource) do 12 | Extension.get_opt(resource, [:json_api_wrapper, :endpoints], :base, nil, false) 13 | end 14 | 15 | @spec tesla(map | Ash.Resource.t()) :: module | nil 16 | def tesla(resource) do 17 | Extension.get_opt( 18 | resource, 19 | [:json_api_wrapper], 20 | :tesla, 21 | AshJsonApiWrapper.DefaultTesla, 22 | false 23 | ) 24 | end 25 | 26 | @spec base_entity_path(map | Ash.Resource.t()) :: String.t() | nil 27 | def base_entity_path(resource) do 28 | Extension.get_opt(resource, [:json_api_wrapper], :base_entity_path, nil, false) 29 | end 30 | 31 | @spec base_paginator(map | Ash.Resource.t()) :: AshJsonApiWrapper.Paginator.ref() 32 | def base_paginator(resource) do 33 | Extension.get_opt(resource, [:json_api_wrapper], :base_paginator, nil, false) 34 | end 35 | 36 | @spec field(map | Ash.Resource.t(), atom) :: AshJsonApiWrapper.Field.t() | nil 37 | def field(resource, name) do 38 | resource 39 | |> fields() 40 | |> Enum.find(&(&1.name == name)) 41 | end 42 | 43 | @spec fields(map | Ash.Resource.t()) :: list(AshJsonApiWrapper.Field.t()) 44 | def fields(resource) do 45 | Extension.get_entities(resource, [:json_api_wrapper, :fields]) 46 | end 47 | 48 | @spec endpoint(map | Ash.Resource.t(), atom) :: AshJsonApiWrapper.Endpoint.t() | nil 49 | def endpoint(resource, action) do 50 | default_endpoint = AshJsonApiWrapper.Endpoint.default(resource) 51 | 52 | resource 53 | |> Extension.get_entities([:json_api_wrapper, :endpoints]) 54 | |> Enum.reject(& &1.get_for) 55 | |> Enum.find(&Enum.member?(&1.action, action)) 56 | |> case do 57 | nil -> 58 | default_endpoint 59 | 60 | endpoint -> 61 | if default_endpoint.path && endpoint.path do 62 | %{endpoint | path: default_endpoint.path <> endpoint.path} 63 | else 64 | %{endpoint | path: endpoint.path || default_endpoint.path} 65 | end 66 | end 67 | end 68 | 69 | @spec get_endpoint(map | Ash.Resource.t(), atom, atom) :: AshJsonApiWrapper.Endpoint.t() | nil 70 | def get_endpoint(resource, action, get_for) do 71 | default_endpoint = AshJsonApiWrapper.Endpoint.default(resource) 72 | 73 | resource 74 | |> Extension.get_entities([:json_api_wrapper, :endpoints]) 75 | |> Enum.find(fn endpoint -> 76 | Enum.member?(endpoint.action, action) && endpoint.get_for == get_for 77 | end) 78 | |> case do 79 | nil -> 80 | nil 81 | 82 | endpoint -> 83 | if default_endpoint.path && endpoint.path do 84 | %{endpoint | path: default_endpoint.path <> endpoint.path} 85 | else 86 | %{endpoint | path: endpoint.path || default_endpoint.path} 87 | end 88 | end 89 | end 90 | 91 | @spec endpoints(map | Ash.Resource.t()) :: list(AshJsonApiWrapper.Endpoint.t()) 92 | def endpoints(resource) do 93 | Extension.get_entities(resource, [:json_api_wrapper, :endpoints]) 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /test/open_api_cybrid_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.OpenApi.CybridTest do 6 | use ExUnit.Case 7 | require Ash.Query 8 | @moduletag :oapi_cybrid 9 | 10 | @json "test/support/cybrid.json" |> File.read!() |> Jason.decode!() 11 | 12 | defmodule TestingTesla do 13 | use Tesla 14 | 15 | plug(Tesla.Middleware.Headers, [ 16 | {"authorization", 17 | "Bearer eyJraWQiOiJTcWZRWXNjelFQbENOSDhxOXZuR1E2WWcwQk1ENm5UZkZMLWhxeER6eFdFIiwiYWxnIjoiUlM1MTIifQ.eyJpc3MiOiJodHRwczovL2lkLnNhbmRib3guY3licmlkLmFwcCIsImF1ZCI6WyJodHRwczovL2Jhbmsuc2FuZGJveC5jeWJyaWQuYXBwIiwiaHR0cDovL3NhbmRib3gtYXBpLWludGVybmFsLWtleTozMDA1IiwiaHR0cHM6Ly9pZC5zYW5kYm94LmN5YnJpZC5hcHAiLCJodHRwOi8vc2FuZGJveC1hcGktaW50ZXJuYWwtYWNjb3VudHM6MzAwMyIsImh0dHA6Ly9zYW5kYm94LWFwaS1pbnRlcm5hbC1pZGVudGl0eTozMDA0IiwiaHR0cDovL3NhbmRib3gtYXBpLWludGVncmF0aW9uLWV4Y2hhbmdlOjMwMDYiLCJodHRwOi8vc2FuZGJveC1hcGktaW50ZWdyYXRpb24tdHJhbnNmZXJzOjMwMDciXSwic3ViIjoiODVjZTljNDgxYjNiN2MwM2YwMTMxOTQ0MjVmODc5MmEiLCJzdWJfdHlwZSI6ImJhbmsiLCJzY29wZSI6WyJiYW5rczpyZWFkIiwiYmFua3M6d3JpdGUiLCJhY2NvdW50czpyZWFkIiwiYWNjb3VudHM6ZXhlY3V0ZSIsImN1c3RvbWVyczpyZWFkIiwiY3VzdG9tZXJzOndyaXRlIiwiY3VzdG9tZXJzOmV4ZWN1dGUiLCJwcmljZXM6cmVhZCIsInF1b3RlczpleGVjdXRlIiwicXVvdGVzOnJlYWQiLCJ0cmFkZXM6ZXhlY3V0ZSIsInRyYWRlczpyZWFkIiwidHJhbnNmZXJzOmV4ZWN1dGUiLCJ0cmFuc2ZlcnM6cmVhZCIsInJld2FyZHM6ZXhlY3V0ZSIsInJld2FyZHM6cmVhZCIsImV4dGVybmFsX2JhbmtfYWNjb3VudHM6cmVhZCIsImV4dGVybmFsX2JhbmtfYWNjb3VudHM6d3JpdGUiLCJleHRlcm5hbF9iYW5rX2FjY291bnRzOmV4ZWN1dGUiLCJleHRlcm5hbF93YWxsZXRzOnJlYWQiLCJleHRlcm5hbF93YWxsZXRzOmV4ZWN1dGUiLCJ3b3JrZmxvd3M6cmVhZCIsIndvcmtmbG93czpleGVjdXRlIiwiZGVwb3NpdF9hZGRyZXNzZXM6cmVhZCIsImRlcG9zaXRfYWRkcmVzc2VzOmV4ZWN1dGUiXSwiaWF0IjoxNjg4MTU4MTE4LCJleHAiOjE2ODgxODY5MTgsImp0aSI6Ijg0ODNhOTg3LWEzZjQtNGU4Mi1iODc3LTg5MDk4YTIyYWE4ZSIsInRva2VuX3R5cGUiOiJhY2Nlc3MiLCJwcm9wZXJ0aWVzIjp7InR5cGUiOiJzYW5kYm94In19.SVFHoZWIKP-owEYzOSfP53nW9oM068t5-CUkPUIlXWPmV_rPTNGhaqjdy9u7iZQvXZX2BF5_gJHx1QR91DBYoR0ftRHxsQTq4UsJChTPfIEZZPuA_lf2iOSy-ivtEdXgqGGHnuItxzS-NnadffSawNXK8Em2Dhfwq7eLps6KvE6fVGelpinvTbfMD7L9PbCdNLdoonEbkdG6eMDV8FEX0sDJhfEd_GUp_HzAKFZwiK_g7NTT4rgm_0Yp6Paue3_ZviDpEWCLhyQNxd-N2TlP4wQng3zafB9_JPX3Z-xKq2WU5z_VltOTHcCMrvsDhDA2oI1CgFT92LMQmC_3QlpCVyaN70Jpd2E-ON9TehQ6JjcNZXoiKl7YaoGDadrAOXdYacexvsNPRpxZhZYxKX3FWtUxYeg0mIHNSS3nd14kfBXVARIqGuBYzRjepmb49MJERNzdeQ-3YectmBVWsPFWfnuMZfWUW54yHR0EF-oWLJJhBqUaZTysyXWpLeKnTBb2t6Q0y9_GLltxZh4x44qRmaq7k511QkEcBbLOnR40HqwnoteCQs-Yqnc8nwHBZ8H6gkUWtQTDiu4_uOmqoqXDx9WDuX4z5pE4M8HzzC1nu7-KvCcqAaCgVQV_Nut0V_IwE-6vB3JOIbFLGjHGMK_WwOrCefKbLoH6ZN6v7wz0cuY"} 18 | ]) 19 | end 20 | 21 | @config [ 22 | tesla: TestingTesla, 23 | endpoint: "https://bank.sandbox.cybrid.app", 24 | resources: [ 25 | "Cybrid.Account": [ 26 | path: "/api/accounts", 27 | object_type: "components.schemas.Account", 28 | primary_key: "guid", 29 | entity_path: "objects", 30 | fields: [ 31 | guid: [ 32 | filter_handler: {:place_in_csv_list, ["guid"]} 33 | ], 34 | bank_guid: [ 35 | filter_handler: {:place_in_csv_list, ["bank_guid"]} 36 | ], 37 | customer_guid: [ 38 | filter_handler: {:place_in_csv_list, ["customer_guid"]} 39 | ] 40 | ] 41 | ] 42 | ] 43 | ] 44 | 45 | defmodule Domain do 46 | use Ash.Domain, 47 | validate_config_inclusion?: false 48 | 49 | resources do 50 | allow_unregistered? true 51 | end 52 | end 53 | 54 | test "it does stuff" do 55 | @json 56 | |> AshJsonApiWrapper.OpenApi.ResourceGenerator.generate(Domain, @config) 57 | |> Enum.map(fn {resource, code} -> 58 | Code.eval_string(code) 59 | resource 60 | end) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/hackernews_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.Hackernews.Test do 6 | use ExUnit.Case 7 | 8 | @moduletag :hackernews 9 | 10 | defmodule TopStory do 11 | @moduledoc false 12 | use Ash.Resource, 13 | domain: AshJsonApiWrapper.Hackernews.Test.Domain, 14 | data_layer: AshJsonApiWrapper.DataLayer, 15 | validate_domain_inclusion?: false 16 | 17 | json_api_wrapper do 18 | endpoints do 19 | base "https://hacker-news.firebaseio.com/v0/" 20 | 21 | endpoint :read do 22 | limit_with {:param, "limitToFirst"} 23 | path "topstories.json" 24 | end 25 | end 26 | 27 | fields do 28 | field :id do 29 | path "" 30 | end 31 | end 32 | end 33 | 34 | attributes do 35 | integer_primary_key(:id) 36 | end 37 | 38 | actions do 39 | defaults([:read]) 40 | end 41 | 42 | relationships do 43 | has_one :story, AshJsonApiWrapper.Hackernews.Test.Story do 44 | source_attribute(:id) 45 | destination_attribute(:id) 46 | end 47 | end 48 | end 49 | 50 | defmodule ShortUrl do 51 | @moduledoc false 52 | use Ash.Resource.Calculation 53 | 54 | def calculate(records, _, _) do 55 | Enum.map(records, fn record -> 56 | URI.parse(record.url) 57 | |> Map.put(:path, nil) 58 | |> Map.put(:scheme, nil) 59 | |> Map.put(:query, nil) 60 | |> to_string() 61 | end) 62 | end 63 | end 64 | 65 | defmodule Story do 66 | @moduledoc false 67 | use Ash.Resource, 68 | domain: AshJsonApiWrapper.Hackernews.Test.Domain, 69 | data_layer: AshJsonApiWrapper.DataLayer, 70 | validate_domain_inclusion?: false 71 | 72 | calculations do 73 | calculate(:short_url, :string, ShortUrl) 74 | end 75 | 76 | preparations do 77 | prepare(build(load: :short_url)) 78 | end 79 | 80 | attributes do 81 | integer_primary_key(:id) 82 | 83 | attribute :by, :string do 84 | allow_nil?(false) 85 | end 86 | 87 | attribute :score, :integer do 88 | allow_nil?(false) 89 | end 90 | 91 | attribute(:title, :string) 92 | attribute(:body, :string) 93 | attribute(:url, :string) 94 | end 95 | 96 | json_api_wrapper do 97 | endpoints do 98 | base "https://hacker-news.firebaseio.com/v0/" 99 | 100 | get_endpoint :read, :id do 101 | path "item/:id.json" 102 | end 103 | end 104 | end 105 | 106 | actions do 107 | defaults([:read]) 108 | end 109 | 110 | relationships do 111 | has_one :user, AshJsonApiWrapper.Hackernews.Test.User do 112 | source_attribute(:by) 113 | destination_attribute(:id) 114 | end 115 | end 116 | end 117 | 118 | defmodule User do 119 | @moduledoc false 120 | use Ash.Resource, 121 | domain: AshJsonApiWrapper.Hackernews.Test.Domain, 122 | data_layer: AshJsonApiWrapper.DataLayer, 123 | validate_domain_inclusion?: false 124 | 125 | attributes do 126 | attribute :id, :string do 127 | primary_key?(true) 128 | allow_nil?(false) 129 | end 130 | end 131 | 132 | json_api_wrapper do 133 | endpoints do 134 | base "https://hacker-news.firebaseio.com/v0/" 135 | 136 | get_endpoint :read, :id do 137 | path "user/:id.json" 138 | end 139 | end 140 | 141 | fields do 142 | field :id do 143 | path "id" 144 | end 145 | end 146 | end 147 | 148 | actions do 149 | defaults([:read]) 150 | end 151 | end 152 | 153 | defmodule Domain do 154 | @moduledoc false 155 | use Ash.Domain, validate_config_inclusion?: false 156 | 157 | resources do 158 | allow_unregistered?(true) 159 | end 160 | end 161 | 162 | test "it works" do 163 | assert [top_story] = 164 | TopStory 165 | |> Ash.Query.limit(1) 166 | |> Ash.Query.load(story: :user) 167 | |> Domain.read!() 168 | |> Enum.map(& &1.story) 169 | 170 | assert is_binary(top_story.url) 171 | assert is_binary(top_story.title) 172 | assert is_binary(top_story.user.id) 173 | assert top_story.by == top_story.user.id 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /test/custom_pagination_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.CustomPagination.Test do 6 | use ExUnit.Case 7 | require Ash.Query 8 | import Mox 9 | @moduletag :custom_pagination 10 | 11 | # Make sure mocks are verified when the test exits 12 | setup :verify_on_exit! 13 | 14 | defmodule TestingTesla do 15 | use Tesla 16 | 17 | adapter(AshJsonApiWrapper.MockAdapter) 18 | # plug(Tesla.Middleware.Logger) 19 | 20 | plug(Tesla.Middleware.Retry, 21 | delay: 2000, 22 | max_retries: 5, 23 | max_delay: 4_000, 24 | should_retry: fn 25 | {:ok, %{status: status}} when status in [429] -> true 26 | {:ok, _} -> false 27 | {:error, _} -> true 28 | end 29 | ) 30 | end 31 | 32 | # ── Custom paginator ── 33 | 34 | defmodule CustomPaginator do 35 | use AshJsonApiWrapper.Paginator 36 | 37 | defp cursor do 38 | case :ets.whereis(:cursor) do 39 | :undefined -> 40 | :ets.new(:cursor, [:set, :protected, :named_table]) 41 | |> :ets.insert({self(), 1}) 42 | 43 | 1 44 | 45 | _ -> 46 | [{_, value} | _rest] = :ets.lookup(:cursor, self()) 47 | value 48 | end 49 | end 50 | 51 | defp increment_cursor do 52 | :ets.insert(:cursor, {self(), cursor() + 1}) 53 | end 54 | 55 | defp reset_cursor do 56 | cursor() 57 | :ets.insert(:cursor, {self(), 1}) 58 | end 59 | 60 | def start(_opts) do 61 | reset_cursor() 62 | {:ok, %{params: %{"p" => 1}}} 63 | end 64 | 65 | def continue(_response, [], _) do 66 | :halt 67 | end 68 | 69 | def continue(_response, _entities, _opts) do 70 | increment_cursor() 71 | {:ok, %{params: %{"p" => cursor()}}} 72 | end 73 | end 74 | 75 | # ── Resource ── 76 | 77 | defmodule Users do 78 | use Ash.Resource, 79 | domain: AshJsonApiWrapper.CustomPagination.Test.Domain, 80 | data_layer: AshJsonApiWrapper.DataLayer, 81 | validate_domain_inclusion?: false 82 | 83 | json_api_wrapper do 84 | tesla(TestingTesla) 85 | 86 | endpoints do 87 | base("https://65383945a543859d1bb1528e.mockapi.io/api/v1") 88 | 89 | endpoint :list_users do 90 | path("/users") 91 | limit_with {:param, "l"} 92 | runtime_sort? true 93 | paginator CustomPaginator 94 | end 95 | end 96 | end 97 | 98 | actions do 99 | read(:list_users) do 100 | primary?(true) 101 | 102 | pagination do 103 | offset?(true) 104 | required?(true) 105 | default_limit(50) 106 | end 107 | end 108 | end 109 | 110 | attributes do 111 | attribute :id, :integer do 112 | primary_key?(true) 113 | allow_nil?(false) 114 | end 115 | 116 | attribute(:name, :string) 117 | end 118 | end 119 | 120 | defmodule Domain do 121 | use Ash.Domain, validate_config_inclusion?: false 122 | 123 | resources do 124 | allow_unregistered?(true) 125 | end 126 | end 127 | 128 | # ── Test it! ── 129 | 130 | test "it works" do 131 | Application.put_env(:ash, :validate_api_resource_inclusion?, false) 132 | Application.put_env(:ash, :validate_api_config_inclusion?, false) 133 | 134 | AshJsonApiWrapper.MockAdapter 135 | |> expect(:call, 4, fn env, _options -> 136 | case env.query do 137 | %{"l" => 3, "p" => 2} -> 138 | {:ok, %Tesla.Env{env | status: 200, body: "[]"}} 139 | 140 | %{"l" => 3, "p" => 1} -> 141 | {:ok, 142 | %Tesla.Env{ 143 | env 144 | | status: 200, 145 | body: """ 146 | [ 147 | {"name": "Kendra Ernser", "id":"1"}, 148 | {"name": "Max Hartman", "id":"2"}, 149 | {"name": "John Benton", "id":"3"} 150 | ] 151 | """ 152 | }} 153 | 154 | query -> 155 | {:ok, 156 | %Tesla.Env{ 157 | env 158 | | status: 500, 159 | body: "Unexpected parameters: #{query |> Kernel.inspect()}" 160 | }} 161 | end 162 | end) 163 | 164 | users = 165 | Users 166 | |> Ash.Query.for_read(:list_users) 167 | # |> Ash.Query.limit(2) 168 | |> Ash.read!(page: [limit: 2, offset: 0]) 169 | 170 | users2 = 171 | Users 172 | |> Ash.Query.for_read(:list_users) 173 | |> Ash.read!(page: [limit: 2, offset: 1]) 174 | 175 | users_count = users.results |> Enum.count() 176 | users2_count = users2.results |> Enum.count() 177 | 178 | assert(users_count == users2_count) 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.MixProject do 6 | use Mix.Project 7 | 8 | @description """ 9 | A data layer for building resources backed by external JSON APIs 10 | """ 11 | 12 | @version "0.1.0" 13 | 14 | def project do 15 | [ 16 | app: :ash_json_api_wrapper, 17 | version: @version, 18 | consolidate_protocols: Mix.env() != :test, 19 | elixir: "~> 1.12", 20 | aliases: aliases(), 21 | start_permanent: Mix.env() == :prod, 22 | deps: deps(), 23 | package: package(), 24 | elixirc_paths: elixirc_paths(Mix.env()), 25 | dialyzer: [plt_add_apps: [:ash]], 26 | docs: docs(), 27 | description: @description, 28 | source_url: "https://github.com/ash-project/ash_json_api", 29 | homepage_url: "https://github.com/ash-project/ash_json_api" 30 | ] 31 | end 32 | 33 | # Run "mix help compile.app" to learn about applications. 34 | def application do 35 | [ 36 | extra_applications: [:logger] 37 | ] 38 | end 39 | 40 | defp extras() do 41 | "documentation/**/*.md" 42 | |> Path.wildcard() 43 | |> Enum.map(fn path -> 44 | title = 45 | path 46 | |> Path.basename(".md") 47 | |> String.split(~r/[-_]/) 48 | |> Enum.map(&String.capitalize/1) 49 | |> Enum.join(" ") 50 | |> case do 51 | "F A Q" -> 52 | "FAQ" 53 | 54 | other -> 55 | other 56 | end 57 | 58 | {String.to_atom(path), 59 | [ 60 | title: title 61 | ]} 62 | end) 63 | end 64 | 65 | defp groups_for_extras() do 66 | "documentation/*" 67 | |> Path.wildcard() 68 | |> Enum.map(fn folder -> 69 | name = 70 | folder 71 | |> Path.basename() 72 | |> String.split(~r/[-_]/) 73 | |> Enum.map(&String.capitalize/1) 74 | |> Enum.join(" ") 75 | 76 | {name, folder |> Path.join("**") |> Path.wildcard()} 77 | end) 78 | end 79 | 80 | defp docs do 81 | [ 82 | main: "AshJsonApiWrapper", 83 | source_ref: "v#{@version}", 84 | logo: "logos/small-logo.png", 85 | extra_section: "GUIDES", 86 | spark: [ 87 | extensions: [ 88 | %{ 89 | module: AshJsonApiWrapper.DataLayer, 90 | name: "AshJsonApiWrapper", 91 | target: "Ash.Resource", 92 | type: "DataLayer" 93 | } 94 | ] 95 | ], 96 | extras: extras(), 97 | groups_for_extras: groups_for_extras(), 98 | groups_for_modules: [ 99 | AshJsonApiWrapper: [ 100 | AshJsonApiWrapper, 101 | AshJsonApiWrapper.DataLayer 102 | ], 103 | Introspection: [ 104 | AshJsonApiWrapper.DataLayer.Info 105 | ], 106 | Internals: ~r/.*/ 107 | ] 108 | ] 109 | end 110 | 111 | defp aliases do 112 | [ 113 | sobelow: "sobelow --skip", 114 | docs: ["docs", "spark.replace_doc_links"], 115 | "spark.formatter": "spark.formatter --extensions AshJsonApiWrapper.DataLayer" 116 | ] 117 | end 118 | 119 | defp package do 120 | [ 121 | maintainers: [ 122 | "Zach Daniel " 123 | ], 124 | licenses: ["MIT"], 125 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* 126 | CHANGELOG* documentation), 127 | links: %{ 128 | "GitHub" => "https://github.com/ash-project/ash_json_api_wrapper", 129 | "Changelog" => 130 | "https://github.com/ash-project/ash_json_api_wrapper/blob/main/CHANGELOG.md", 131 | "Discord" => "https://discord.gg/HTHRaaVPUc", 132 | "Website" => "https://ash-hq.org", 133 | "Forum" => "https://elixirforum.com/c/elixir-framework-forums/ash-framework-forum", 134 | "REUSE Compliance" => 135 | "https://api.reuse.software/info/github.com/ash-project/ash_json_api_wrapper" 136 | } 137 | ] 138 | end 139 | 140 | # Run "mix help deps" to learn about dependencies. 141 | defp deps do 142 | [ 143 | {:ash, ash_version("~> 3.0")}, 144 | {:tesla, "~> 1.7"}, 145 | {:exjsonpath, "~> 0.1"}, 146 | # Dev/Test dependencies 147 | {:igniter, "~> 0.5", optional: true}, 148 | {:ex_doc, "~> 0.22", only: :dev, runtime: false}, 149 | {:ex_check, "~> 0.16.0", only: :dev}, 150 | {:credo, ">= 0.0.0", only: [:dev, :test], runtime: false}, 151 | {:dialyxir, ">= 0.0.0", only: :dev, runtime: false}, 152 | {:sobelow, "~> 0.13", only: [:dev, :test], runtime: false}, 153 | {:git_ops, "~> 2.5", only: :dev}, 154 | {:mix_test_watch, "~> 1.0", only: :dev, runtime: false}, 155 | {:parse_trans, "3.4.2", only: [:dev, :test], override: true}, 156 | {:mox, "~> 1.0", only: :test}, 157 | {:mix_audit, ">= 0.0.0", only: [:dev, :test], runtime: false} 158 | ] 159 | end 160 | 161 | defp ash_version(default_version) do 162 | case System.get_env("ASH_VERSION") do 163 | nil -> default_version 164 | "local" -> [path: "../ash"] 165 | "main" -> [git: "https://github.com/ash-project/ash.git"] 166 | version -> "~> #{version}" 167 | end 168 | end 169 | 170 | defp elixirc_paths(:test) do 171 | elixirc_paths(:dev) ++ ["test/support"] 172 | end 173 | 174 | defp elixirc_paths(_) do 175 | ["lib"] 176 | end 177 | end 178 | -------------------------------------------------------------------------------- /lib/open_api/resource_generator.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.OpenApi.ResourceGenerator do 6 | @moduledoc "Generates resources from an open api specification" 7 | 8 | # sobelow_skip ["DOS.StringToAtom"] 9 | def generate(json, domain, main_config) do 10 | main_config[:resources] 11 | |> Enum.map(fn {resource, config} -> 12 | endpoints = 13 | json 14 | |> operations(config) 15 | |> Enum.map_join("\n\n", fn {path, _method, operation} -> 16 | entity_path = 17 | if config[:entity_path] do 18 | "entity_path \"#{config[:entity_path]}\"" 19 | end 20 | 21 | """ 22 | endpoint :#{operation_id(operation)} do 23 | path "#{path}" 24 | #{entity_path} 25 | end 26 | """ 27 | end) 28 | 29 | actions = 30 | json 31 | |> operations(config) 32 | |> Enum.map_join("\n\n", fn 33 | {_path, "get", config} -> 34 | """ 35 | read :#{operation_id(config)} 36 | """ 37 | 38 | {_path, "post", config} -> 39 | """ 40 | create :#{operation_id(config)} 41 | """ 42 | end) 43 | 44 | fields = 45 | config[:fields] 46 | |> Enum.map_join("\n\n", fn {name, field_config} -> 47 | filter_handler = 48 | if field_config[:filter_handler] do 49 | "filter_handler #{inspect(field_config[:filter_handler])}" 50 | end 51 | 52 | """ 53 | field #{inspect(name)} do 54 | #{filter_handler} 55 | end 56 | """ 57 | end) 58 | |> case do 59 | "" -> 60 | "" 61 | 62 | other -> 63 | """ 64 | fields do 65 | #{other} 66 | end 67 | """ 68 | end 69 | 70 | {:ok, [object]} = 71 | json 72 | |> ExJSONPath.eval(config[:object_type]) 73 | 74 | attributes = 75 | object 76 | |> Map.get("properties") 77 | |> Enum.map(fn {name, config} -> 78 | {Macro.underscore(name), config} 79 | end) 80 | |> Enum.sort_by(fn {name, _} -> 81 | name not in List.wrap(config[:primary_key]) 82 | end) 83 | |> Enum.map_join("\n\n", fn {name, property} -> 84 | type = 85 | case property do 86 | %{"enum" => _values} -> 87 | ":atom" 88 | 89 | %{"format" => "date-time"} -> 90 | ":utc_datetime" 91 | 92 | %{"type" => "string"} -> 93 | ":string" 94 | 95 | %{"type" => "integer"} -> 96 | ":integer" 97 | 98 | %{"type" => "boolean"} -> 99 | ":boolean" 100 | 101 | other -> 102 | raise "Unsupported property: #{inspect(other)}" 103 | end 104 | 105 | constraints = 106 | case property do 107 | %{"enum" => values} -> 108 | "one_of: #{inspect(Enum.map(values, &String.to_atom/1))}" 109 | 110 | %{"maxLength" => max, "minLength" => min, "type" => "string"} -> 111 | "min_length: #{min}, max_length: #{max}" 112 | 113 | %{"maxLength" => max, "type" => "string"} -> 114 | "max_length: #{max}" 115 | 116 | %{"minLength" => min, "type" => "string"} -> 117 | "min_length: #{min}" 118 | 119 | _ -> 120 | nil 121 | end 122 | 123 | primary_key? = name in List.wrap(config[:primary_key]) 124 | 125 | if constraints || primary_key? do 126 | constraints = 127 | if constraints do 128 | "constraints #{constraints}" 129 | end 130 | 131 | primary_key = 132 | if primary_key? do 133 | """ 134 | primary_key? true 135 | allow_nil? false 136 | """ 137 | end 138 | 139 | """ 140 | attribute :#{name}, #{type} do 141 | #{primary_key} 142 | #{constraints} 143 | end 144 | """ 145 | else 146 | """ 147 | attribute :#{name}, #{type} 148 | """ 149 | end 150 | end) 151 | 152 | tesla = 153 | if main_config[:tesla] do 154 | "tesla #{main_config[:tesla]}" 155 | end 156 | 157 | endpoint = 158 | if main_config[:endpoint] do 159 | "base \"#{main_config[:endpoint]}\"" 160 | end 161 | 162 | code = 163 | """ 164 | defmodule #{resource} do 165 | use Ash.Resource, domain: #{inspect(domain)}, data_layer: AshJsonApiWrapper.DataLayer 166 | 167 | json_api_wrapper do 168 | #{tesla} 169 | 170 | endpoints do 171 | #{endpoint} 172 | #{endpoints} 173 | end 174 | 175 | #{fields} 176 | end 177 | 178 | actions do 179 | #{actions} 180 | end 181 | 182 | attributes do 183 | #{attributes} 184 | end 185 | end 186 | """ 187 | |> Code.format_string!() 188 | |> IO.iodata_to_binary() 189 | 190 | {resource, code} 191 | end) 192 | end 193 | 194 | defp operation_id(%{"operationId" => operationId}) do 195 | operationId 196 | |> Macro.underscore() 197 | end 198 | 199 | defp operations(json, config) do 200 | json["paths"] 201 | |> Enum.filter(fn {path, _value} -> 202 | String.starts_with?(path, config[:path]) 203 | end) 204 | |> Enum.flat_map(fn {path, methods} -> 205 | Enum.map(methods, fn {method, config} -> 206 | {path, method, config} 207 | end) 208 | end) 209 | |> Enum.filter(fn {_path, method, _config} -> 210 | method in ["get", "post"] 211 | end) 212 | end 213 | end 214 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # This file contains the configuration for Credo and you are probably reading 6 | # this after creating it with `mix credo.gen.config`. 7 | # 8 | # If you find anything wrong or unclear in this file, please report an 9 | # issue on GitHub: https://github.com/rrrene/credo/issues 10 | # 11 | %{ 12 | # 13 | # You can have as many configs as you like in the `configs:` field. 14 | configs: [ 15 | %{ 16 | # 17 | # Run any config using `mix credo -C `. If no config name is given 18 | # "default" is used. 19 | # 20 | name: "default", 21 | # 22 | # These are the files included in the analysis: 23 | files: %{ 24 | # 25 | # You can give explicit globs or simply directories. 26 | # In the latter case `**/*.{ex,exs}` will be used. 27 | # 28 | included: [ 29 | "lib/", 30 | "src/", 31 | "test/", 32 | "web/", 33 | "apps/*/lib/", 34 | "apps/*/src/", 35 | "apps/*/test/", 36 | "apps/*/web/" 37 | ], 38 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 39 | }, 40 | # 41 | # Load and configure plugins here: 42 | # 43 | plugins: [], 44 | # 45 | # If you create your own checks, you must specify the source files for 46 | # them here, so they can be loaded by Credo before running the analysis. 47 | # 48 | requires: [], 49 | # 50 | # If you want to enforce a style guide and need a more traditional linting 51 | # experience, you can change `strict` to `true` below: 52 | # 53 | strict: false, 54 | # 55 | # To modify the timeout for parsing files, change this value: 56 | # 57 | parse_timeout: 5000, 58 | # 59 | # If you want to use uncolored output by default, you can change `color` 60 | # to `false` below: 61 | # 62 | color: true, 63 | # 64 | # You can customize the parameters of any check by adding a second element 65 | # to the tuple. 66 | # 67 | # To disable a check put `false` as second element: 68 | # 69 | # {Credo.Check.Design.DuplicatedCode, false} 70 | # 71 | checks: [ 72 | # 73 | ## Consistency Checks 74 | # 75 | {Credo.Check.Consistency.ExceptionNames, []}, 76 | {Credo.Check.Consistency.LineEndings, []}, 77 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 78 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 79 | {Credo.Check.Consistency.SpaceInParentheses, []}, 80 | {Credo.Check.Consistency.TabsOrSpaces, []}, 81 | 82 | # 83 | ## Design Checks 84 | # 85 | # You can customize the priority of any check 86 | # Priority values are: `low, normal, high, higher` 87 | # 88 | {Credo.Check.Design.AliasUsage, false}, 89 | # You can also customize the exit_status of each check. 90 | # If you don't want TODO comments to cause `mix credo` to fail, just 91 | # set this value to 0 (zero). 92 | # 93 | {Credo.Check.Design.TagTODO, false}, 94 | {Credo.Check.Design.TagFIXME, []}, 95 | 96 | # 97 | ## Readability Checks 98 | # 99 | {Credo.Check.Readability.AliasOrder, []}, 100 | {Credo.Check.Readability.FunctionNames, []}, 101 | {Credo.Check.Readability.LargeNumbers, []}, 102 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 103 | {Credo.Check.Readability.ModuleAttributeNames, []}, 104 | {Credo.Check.Readability.ModuleDoc, []}, 105 | {Credo.Check.Readability.ModuleNames, []}, 106 | {Credo.Check.Readability.ParenthesesInCondition, []}, 107 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 108 | {Credo.Check.Readability.PredicateFunctionNames, []}, 109 | {Credo.Check.Readability.PreferImplicitTry, []}, 110 | {Credo.Check.Readability.RedundantBlankLines, []}, 111 | {Credo.Check.Readability.Semicolons, []}, 112 | {Credo.Check.Readability.SpaceAfterCommas, []}, 113 | {Credo.Check.Readability.StringSigils, []}, 114 | {Credo.Check.Readability.TrailingBlankLine, []}, 115 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 116 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 117 | {Credo.Check.Readability.VariableNames, []}, 118 | 119 | # 120 | ## Refactoring Opportunities 121 | # 122 | {Credo.Check.Refactor.CondStatements, []}, 123 | {Credo.Check.Refactor.CyclomaticComplexity, false}, 124 | {Credo.Check.Refactor.FunctionArity, [max_arity: 12]}, 125 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 126 | {Credo.Check.Refactor.MapInto, false}, 127 | {Credo.Check.Refactor.MatchInCondition, []}, 128 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 129 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 130 | {Credo.Check.Refactor.Nesting, [max_nesting: 8]}, 131 | {Credo.Check.Refactor.UnlessWithElse, []}, 132 | {Credo.Check.Refactor.WithClauses, []}, 133 | 134 | # 135 | ## Warnings 136 | # 137 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 138 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 139 | {Credo.Check.Warning.IExPry, []}, 140 | {Credo.Check.Warning.IoInspect, []}, 141 | {Credo.Check.Warning.LazyLogging, false}, 142 | {Credo.Check.Warning.MixEnv, false}, 143 | {Credo.Check.Warning.OperationOnSameValues, []}, 144 | {Credo.Check.Warning.OperationWithConstantResult, []}, 145 | {Credo.Check.Warning.RaiseInsideRescue, []}, 146 | {Credo.Check.Warning.UnusedEnumOperation, []}, 147 | {Credo.Check.Warning.UnusedFileOperation, []}, 148 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 149 | {Credo.Check.Warning.UnusedListOperation, []}, 150 | {Credo.Check.Warning.UnusedPathOperation, []}, 151 | {Credo.Check.Warning.UnusedRegexOperation, []}, 152 | {Credo.Check.Warning.UnusedStringOperation, []}, 153 | {Credo.Check.Warning.UnusedTupleOperation, []}, 154 | {Credo.Check.Warning.UnsafeExec, []}, 155 | 156 | # 157 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 158 | 159 | # 160 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 161 | # 162 | {Credo.Check.Readability.StrictModuleLayout, false}, 163 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 164 | {Credo.Check.Consistency.UnusedVariableNames, false}, 165 | {Credo.Check.Design.DuplicatedCode, false}, 166 | {Credo.Check.Readability.AliasAs, false}, 167 | {Credo.Check.Readability.MultiAlias, false}, 168 | {Credo.Check.Readability.Specs, false}, 169 | {Credo.Check.Readability.SinglePipe, false}, 170 | {Credo.Check.Readability.WithCustomTaggedTuple, false}, 171 | {Credo.Check.Refactor.ABCSize, false}, 172 | {Credo.Check.Refactor.AppendSingleItem, false}, 173 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 174 | {Credo.Check.Refactor.ModuleDependencies, false}, 175 | {Credo.Check.Refactor.NegatedIsNil, false}, 176 | {Credo.Check.Refactor.PipeChainStart, false}, 177 | {Credo.Check.Refactor.VariableRebinding, false}, 178 | {Credo.Check.Warning.LeakyEnvironment, false}, 179 | {Credo.Check.Warning.MapGetUnsafePass, false}, 180 | {Credo.Check.Warning.UnsafeToAtom, false} 181 | 182 | # 183 | # Custom checks can be created using `mix credo.gen.check`. 184 | # 185 | ] 186 | } 187 | ] 188 | } 189 | -------------------------------------------------------------------------------- /lib/filter.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.Filter do 6 | @moduledoc false 7 | 8 | def find_simple_filter(%Ash.Filter{expression: expression}, field) do 9 | find_simple_filter(expression, field) 10 | end 11 | 12 | def find_simple_filter( 13 | %Ash.Query.BooleanExpression{op: :and, left: left, right: right} = expr, 14 | field 15 | ) do 16 | case find_simple_filter(left, field) do 17 | {:ok, nil} -> 18 | case find_simple_filter(right, field) do 19 | {:ok, nil} -> 20 | {:ok, expr, []} 21 | 22 | {:ok, {right_remaining, right_instructions}} -> 23 | {:ok, Ash.Query.BooleanExpression.new(:and, left, right_remaining), 24 | right_instructions} 25 | end 26 | 27 | {:ok, {left_remaining, left_instructions}} -> 28 | case find_simple_filter(right, field) do 29 | {:ok, nil} -> 30 | {:ok, 31 | {Ash.Query.BooleanExpression.new(:and, left_remaining, right), left_instructions}} 32 | 33 | {:ok, {right_remaining, right_instructions}} -> 34 | {:ok, 35 | {Ash.Query.BooleanExpression.new(:and, left_remaining, right_remaining), 36 | left_instructions ++ right_instructions}} 37 | end 38 | end 39 | end 40 | 41 | def find_simple_filter( 42 | %Ash.Query.Operator.Eq{left: left, right: %Ash.Query.Ref{} = right} = op, 43 | field 44 | ) do 45 | find_simple_filter(%{op | right: left, left: right}, field) 46 | end 47 | 48 | def find_simple_filter( 49 | %Ash.Query.Operator.Eq{ 50 | left: %Ash.Query.Ref{relationship_path: [], attribute: %{name: name}} 51 | }, 52 | field 53 | ) 54 | when name != field do 55 | {:ok, nil} 56 | end 57 | 58 | def find_simple_filter( 59 | %Ash.Query.Operator.Eq{ 60 | left: %Ash.Query.Ref{relationship_path: [], attribute: %{name: field}}, 61 | right: value 62 | }, 63 | field 64 | ) do 65 | {:ok, {nil, [{:set, field, value}]}} 66 | end 67 | 68 | def find_simple_filter( 69 | %Ash.Query.Operator.In{ 70 | left: %Ash.Query.Ref{relationship_path: [], attribute: %{name: field}}, 71 | right: values 72 | }, 73 | field 74 | ) do 75 | {:ok, {nil, [{:expand_set, field, values}]}} 76 | end 77 | 78 | def find_simple_filter(_, _) do 79 | {:ok, nil} 80 | end 81 | 82 | def find_place_in_list_filter( 83 | filter, 84 | field, 85 | path, 86 | type, 87 | context \\ %{in_an_or?: false, other_branch_instructions: nil} 88 | ) 89 | 90 | def find_place_in_list_filter(nil, _, _, _, _), do: {:ok, nil} 91 | 92 | def find_place_in_list_filter(%Ash.Filter{expression: expression}, field, path, type, context) do 93 | find_place_in_list_filter(expression, field, path, type, context) 94 | end 95 | 96 | def find_place_in_list_filter( 97 | %Ash.Query.BooleanExpression{op: op, left: left, right: right} = expr, 98 | field, 99 | path, 100 | type, 101 | context 102 | ) do 103 | case find_place_in_list_filter(left, field, path, type, context) do 104 | {:ok, nil} -> 105 | case find_place_in_list_filter(right, field, path, type, context) do 106 | {:ok, nil} -> 107 | {:ok, expr, []} 108 | 109 | {:ok, {right_remaining, right_instructions}} -> 110 | {:ok, Ash.Query.BooleanExpression.new(op, left, right_remaining), right_instructions} 111 | end 112 | 113 | {:ok, {left_remaining, left_instructions}} -> 114 | case find_place_in_list_filter(right, field, path, %{ 115 | context 116 | | other_branch_instructions: left_instructions 117 | }) do 118 | {:ok, nil} -> 119 | {:ok, {Ash.Query.BooleanExpression.new(op, left_remaining, right), left_instructions}} 120 | 121 | {:ok, {right_remaining, right_instructions}} -> 122 | {:ok, 123 | {Ash.Query.BooleanExpression.new(op, left_remaining, right_remaining), 124 | left_instructions ++ right_instructions}} 125 | end 126 | end 127 | end 128 | 129 | def find_place_in_list_filter( 130 | %Ash.Query.Operator.Eq{left: left, right: %Ash.Query.Ref{} = right} = op, 131 | field, 132 | path, 133 | type, 134 | context 135 | ) do 136 | find_place_in_list_filter(%{op | right: left, left: right}, field, path, type, context) 137 | end 138 | 139 | def find_place_in_list_filter( 140 | %Ash.Query.Operator.In{left: left, right: %Ash.Query.Ref{} = right} = op, 141 | field, 142 | path, 143 | type, 144 | context 145 | ) do 146 | find_place_in_list_filter(%{op | right: left, left: right}, field, path, type, context) 147 | end 148 | 149 | def find_place_in_list_filter( 150 | %Ash.Query.Operator.Eq{ 151 | left: %Ash.Query.Ref{relationship_path: [], attribute: %{name: name}} 152 | }, 153 | field, 154 | _path, 155 | _type, 156 | _context 157 | ) 158 | when name != field do 159 | {:ok, nil} 160 | end 161 | 162 | def find_place_in_list_filter( 163 | %Ash.Query.Operator.In{ 164 | left: %Ash.Query.Ref{relationship_path: [], attribute: %{name: name}} 165 | }, 166 | field, 167 | _path, 168 | _type, 169 | _context 170 | ) 171 | when name != field do 172 | {:ok, nil} 173 | end 174 | 175 | def find_place_in_list_filter( 176 | %Ash.Query.Operator.Eq{ 177 | left: %Ash.Query.Ref{relationship_path: [], attribute: %{name: field}}, 178 | right: value 179 | }, 180 | field, 181 | path, 182 | type, 183 | _context 184 | ) do 185 | {:ok, {nil, [{type, path, value}]}} 186 | end 187 | 188 | def find_place_in_list_filter( 189 | %Ash.Query.Operator.In{ 190 | left: %Ash.Query.Ref{relationship_path: [], attribute: %{name: field}}, 191 | right: values 192 | }, 193 | field, 194 | path, 195 | type, 196 | _context 197 | ) do 198 | {:ok, {nil, Enum.map(values, &{type, path, &1})}} 199 | end 200 | 201 | def find_filter_that_uses_get_endpoint( 202 | expr, 203 | resource, 204 | action, 205 | templates \\ nil, 206 | in_an_or? \\ false, 207 | uses_endpoint \\ nil 208 | ) 209 | 210 | def find_filter_that_uses_get_endpoint( 211 | %Ash.Filter{expression: expression}, 212 | resource, 213 | action, 214 | templates, 215 | in_an_or?, 216 | uses_endpoint 217 | ) do 218 | find_filter_that_uses_get_endpoint( 219 | expression, 220 | resource, 221 | action, 222 | templates, 223 | in_an_or?, 224 | uses_endpoint 225 | ) 226 | end 227 | 228 | def find_filter_that_uses_get_endpoint( 229 | %Ash.Query.BooleanExpression{op: :and, left: left, right: right}, 230 | resource, 231 | action, 232 | templates, 233 | in_an_or?, 234 | uses_endpoint 235 | ) do 236 | case find_filter_that_uses_get_endpoint(left, resource, action, templates, in_an_or?) do 237 | {:ok, {left_remaining, get_endpoint, left_templates}} -> 238 | if uses_endpoint && get_endpoint != uses_endpoint do 239 | {:error, 240 | "Filter would cause the usage of different endpoints: #{inspect(uses_endpoint)} and #{inspect(get_endpoint)}"} 241 | else 242 | case find_filter_that_uses_get_endpoint(right, resource, action, templates, in_an_or?) do 243 | {:ok, {right_remaining, get_endpoint, right_templates}} -> 244 | if uses_endpoint && get_endpoint != uses_endpoint do 245 | {:error, 246 | "Filter would cause the usage of different endpoints: #{inspect(uses_endpoint)} and #{inspect(get_endpoint)}"} 247 | else 248 | {:ok, 249 | {Ash.Query.BooleanExpression.new(:and, left_remaining, right_remaining), 250 | uses_endpoint, add_templates([left_templates, right_templates, templates])}} 251 | end 252 | 253 | {:ok, nil} -> 254 | {:ok, 255 | {Ash.Query.BooleanExpression.new(:and, left_remaining, right), uses_endpoint, 256 | add_templates([left_templates, templates])}} 257 | 258 | {:error, error} -> 259 | {:error, error} 260 | end 261 | end 262 | 263 | {:ok, nil} -> 264 | case find_filter_that_uses_get_endpoint(right, resource, action, templates, in_an_or?) do 265 | {:ok, {right_remaining, get_endpoint, right_templates}} -> 266 | if uses_endpoint && get_endpoint != uses_endpoint do 267 | {:error, 268 | "Filter would cause the usage of different endpoints: #{inspect(uses_endpoint)} and #{inspect(get_endpoint)}"} 269 | else 270 | {:ok, 271 | {Ash.Query.BooleanExpression.new(:and, left, right_remaining), uses_endpoint, 272 | add_templates([right_templates, templates])}} 273 | end 274 | 275 | {:ok, nil} -> 276 | {:ok, {Ash.Query.BooleanExpression.new(:and, left, right), uses_endpoint, nil}} 277 | end 278 | 279 | {:error, error} -> 280 | {:error, error} 281 | end 282 | end 283 | 284 | def find_filter_that_uses_get_endpoint( 285 | %Ash.Query.BooleanExpression{op: :or, left: left, right: right} = expr, 286 | resource, 287 | action, 288 | templates, 289 | _in_an_or?, 290 | uses_endpoint 291 | ) do 292 | case find_filter_that_uses_get_endpoint(left, resource, action, templates, true) do 293 | {:ok, nil} -> 294 | case find_filter_that_uses_get_endpoint(right, resource, action, templates, true) do 295 | {:ok, nil} -> 296 | {:ok, {expr, uses_endpoint, nil}} 297 | 298 | {:error, error} -> 299 | {:error, error} 300 | end 301 | 302 | {:error, error} -> 303 | {:error, error} 304 | 305 | _ -> 306 | raise "Unreachable!" 307 | end 308 | end 309 | 310 | def find_filter_that_uses_get_endpoint( 311 | %Ash.Query.Operator.Eq{left: %Ash.Query.Ref{}, right: %Ash.Query.Ref{}} = expr, 312 | _, 313 | _, 314 | _, 315 | _, 316 | uses_endpoint 317 | ) do 318 | {:ok, {expr, uses_endpoint, nil}} 319 | end 320 | 321 | def find_filter_that_uses_get_endpoint( 322 | %Ash.Query.Operator.Eq{ 323 | left: left, 324 | right: %Ash.Query.Ref{} = right 325 | } = op, 326 | resource, 327 | action, 328 | templates, 329 | in_an_or?, 330 | uses_endpoint 331 | ) do 332 | find_filter_that_uses_get_endpoint( 333 | %{op | right: left, left: right}, 334 | resource, 335 | action, 336 | templates, 337 | in_an_or?, 338 | uses_endpoint 339 | ) 340 | end 341 | 342 | def find_filter_that_uses_get_endpoint( 343 | %Ash.Query.Operator.In{ 344 | left: left, 345 | right: %Ash.Query.Ref{} = right 346 | } = op, 347 | resource, 348 | action, 349 | templates, 350 | in_an_or?, 351 | uses_endpoint 352 | ) do 353 | find_filter_that_uses_get_endpoint( 354 | %{op | right: left, left: right}, 355 | resource, 356 | action, 357 | templates, 358 | in_an_or?, 359 | uses_endpoint 360 | ) 361 | end 362 | 363 | def find_filter_that_uses_get_endpoint( 364 | %Ash.Query.Operator.Eq{ 365 | left: %Ash.Query.Ref{relationship_path: [], attribute: attribute}, 366 | right: value 367 | }, 368 | resource, 369 | action, 370 | templates, 371 | in_an_or?, 372 | uses_endpoint 373 | ) do 374 | case AshJsonApiWrapper.DataLayer.Info.get_endpoint(resource, action.name, attribute.name) do 375 | nil -> 376 | {:ok, nil} 377 | 378 | get_endpoint -> 379 | if in_an_or? do 380 | {:error, "Cannot use get_endpoint attributes in an `or` clause of a filter."} 381 | else 382 | if uses_endpoint && get_endpoint != uses_endpoint do 383 | {:error, 384 | "Filter would cause the usage of different endpoints: #{inspect(uses_endpoint)} and #{inspect(get_endpoint)}"} 385 | else 386 | {:ok, {nil, get_endpoint, add_templates([[{attribute.name, value}], templates])}} 387 | end 388 | end 389 | end 390 | end 391 | 392 | def find_filter_that_uses_get_endpoint( 393 | %Ash.Query.Operator.In{ 394 | left: %Ash.Query.Ref{relationship_path: [], attribute: attribute}, 395 | right: values 396 | }, 397 | resource, 398 | action, 399 | templates, 400 | in_an_or?, 401 | uses_endpoint 402 | ) do 403 | case AshJsonApiWrapper.DataLayer.Info.get_endpoint(resource, action.name, attribute.name) do 404 | nil -> 405 | {:ok, nil} 406 | 407 | get_endpoint -> 408 | if in_an_or? do 409 | {:error, "Cannot use get_endpoint attributes in an `or` clause of a filter."} 410 | else 411 | if uses_endpoint && get_endpoint != uses_endpoint do 412 | {:error, 413 | "Filter would cause the usage of different endpoints: #{inspect(uses_endpoint)} and #{inspect(get_endpoint)}"} 414 | else 415 | {:ok, 416 | {nil, get_endpoint, 417 | add_templates([Enum.map(values, &{attribute.name, &1}), templates])}} 418 | end 419 | end 420 | end 421 | end 422 | 423 | defp add_templates(templates) do 424 | if Enum.all?(templates, &is_nil/1) do 425 | nil 426 | else 427 | Enum.flat_map(templates, &List.wrap/1) 428 | end 429 | end 430 | end 431 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "ash": {:hex, :ash, "3.6.2", "90d1c8296be777b90caabf51b99323d6618a0b92594dfab92b02bdf848ac38bf", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3546b5798dd24576cc451f6e03f3d6e3bb62666c0921bfe8aae700c599d9c38d"}, 3 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 4 | "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [: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", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, 5 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 6 | "dialyxir": {:hex, :dialyxir, "1.4.6", "7cca478334bf8307e968664343cbdb432ee95b4b68a9cba95bdabb0ad5bdfd9a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "8cf5615c5cd4c2da6c501faae642839c8405b49f8aa057ad4ae401cb808ef64d"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, 8 | "ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"}, 9 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 10 | "ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"}, 11 | "ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"}, 12 | "ex_doc": {:hex, :ex_doc, "0.38.4", "ab48dff7a8af84226bf23baddcdda329f467255d924380a0cf0cee97bb9a9ede", [: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", "f7b62346408a83911c2580154e35613eb314e0278aeea72ed7fedef9c1f165b2"}, 13 | "exjsonpath": {:hex, :exjsonpath, "0.9.0", "87e593eb0deb53aa0688ca9f9edc9fb3456aca83c82245f83201ea04d696feba", [:mix], [], "hexpm", "8d7a8e9ba784e1f7a67c6f1074a3ac91a3a79a45969514ee5d95cea5bf749627"}, 14 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 15 | "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, 16 | "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, 17 | "git_ops": {:hex, :git_ops, "2.9.0", "b74f6040084f523055b720cc7ef718da47f2cbe726a5f30c2871118635cb91c1", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.27 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "7fdf84be3490e5692c5dc1f8a1084eed47a221c1063e41938c73312f0bfea259"}, 18 | "glob_ex": {:hex, :glob_ex, "0.1.11", "cb50d3f1ef53f6ca04d6252c7fde09fd7a1cf63387714fe96f340a1349e62c93", [:mix], [], "hexpm", "342729363056e3145e61766b416769984c329e4378f1d558b63e341020525de4"}, 19 | "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, 20 | "igniter": {:hex, :igniter, "0.6.30", "83a466369ebb8fe009e0823c7bf04314dc545122c2d48f896172fc79df33e99d", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "76a14d5b7f850bb03b5243088c3649d54a2e52e34a2aa1104dee23cf50a8bae0"}, 21 | "iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"}, 22 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 23 | "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, 24 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 25 | "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"}, 26 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 27 | "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, 28 | "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, 29 | "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, 30 | "mix_test_watch": {:hex, :mix_test_watch, "1.3.0", "2ffc9f72b0d1f4ecf0ce97b044e0e3c607c3b4dc21d6228365e8bc7c2856dc77", [:mix], [{:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "f9e5edca976857ffac78632e635750d158df14ee2d6185a15013844af7570ffe"}, 31 | "mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"}, 32 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 33 | "nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"}, 34 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 35 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 36 | "owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"}, 37 | "parse_trans": {:hex, :parse_trans, "3.4.2", "c352ddc1a0d5e54f9b1654d45f9c432eef76f9cea371c55ddff769ef688fdb74", [:rebar3], [], "hexpm", "4c25347de3b7c35732d32e69ab43d1ceee0beae3f3b3ade1b59cbd3dd224d9ca"}, 38 | "reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"}, 39 | "req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"}, 40 | "rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"}, 41 | "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, 42 | "sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"}, 43 | "spark": {:hex, :spark, "2.3.5", "f30d30ecc3b4ab9b932d9aada66af7677fc1f297a2c349b0bcec3eafb9f996e8", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "0e9d339704d5d148f77f2b2fef3bcfc873a9e9bb4224fcf289c545d65827202f"}, 44 | "spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"}, 45 | "splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"}, 46 | "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, 47 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 48 | "tesla": {:hex, :tesla, "1.15.3", "3a2b5c37f09629b8dcf5d028fbafc9143c0099753559d7fe567eaabfbd9b8663", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.13", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, ">= 1.0.0", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "4.4.2", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:mox, "~> 1.0", [hex: :mox, repo: "hexpm", optional: true]}, {:msgpax, "~> 2.3", [hex: :msgpax, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "98bb3d4558abc67b92fb7be4cd31bb57ca8d80792de26870d362974b58caeda7"}, 49 | "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, 50 | "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, 51 | "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, 52 | "ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"}, 53 | } 54 | -------------------------------------------------------------------------------- /lib/data_layer/data_layer.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2021 ash_json_api_wrapper contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule AshJsonApiWrapper.DataLayer do 6 | @moduledoc """ 7 | A data layer for wrapping external JSON APIs. 8 | """ 9 | 10 | @field %Spark.Dsl.Entity{ 11 | name: :field, 12 | target: AshJsonApiWrapper.Field, 13 | schema: AshJsonApiWrapper.Field.schema(), 14 | docs: """ 15 | Configure an individual field's behavior, for example its path in the response. 16 | """, 17 | args: [:name] 18 | } 19 | 20 | @fields %Spark.Dsl.Section{ 21 | name: :fields, 22 | describe: "Contains configuration for individual fields in the response", 23 | entities: [ 24 | @field 25 | ] 26 | } 27 | 28 | @endpoint %Spark.Dsl.Entity{ 29 | name: :endpoint, 30 | target: AshJsonApiWrapper.Endpoint, 31 | schema: AshJsonApiWrapper.Endpoint.schema(), 32 | identifier: {:auto, :unique_integer}, 33 | docs: """ 34 | Configure the endpoint that a given action will use. 35 | 36 | Accepts overrides for fields as well. 37 | """, 38 | entities: [ 39 | fields: [@field] 40 | ], 41 | args: [:action] 42 | } 43 | 44 | @get_endpoint %Spark.Dsl.Entity{ 45 | name: :get_endpoint, 46 | target: AshJsonApiWrapper.Endpoint, 47 | schema: AshJsonApiWrapper.Endpoint.get_schema(), 48 | docs: """ 49 | Configure the endpoint that a given action will use. 50 | 51 | Accepts overrides for fields as well. 52 | 53 | Expresses that this endpoint is used to fetch a single item. 54 | Doing this will make the data layer support equality filters over that field when using that action. 55 | If "in" or "or equals" is used, then multiple requests will be made in parallel to fetch 56 | all of those records. However, keep in mind you can't combine a filter over one of these 57 | fields with an `or` with anything other than *more* filters on this field. For example Doing this will make the data layer support equality filters over that field. 58 | If "in" or "or equals" is used, then multiple requests will be made in parallel to fetch 59 | all of those records. However, keep in mind you can't combine a filter over one of these 60 | fields with an `or` with anything other than *more* filters on this field. For example, 61 | `filter(resource, id == 1 or foo == true)`, since we wouldn't be able to turn this into 62 | multiple requests to the get endpoint for `id`. If other filters are supported, they can be used 63 | with `and`, e.g `filter(resource, id == 1 or id == 2 and other_supported_filter == true)`, since those 64 | filters will be applied to each request. 65 | 66 | Expects the field to be available in the path template, e.g with `get_for` of `:id`, path should contain `:id`, e.g 67 | `/get/:id` or `/:id`, 68 | `filter(resource, id == 1 or foo == true)`, since we wouldn't be able to turn this into 69 | multiple requests to the get endpoint for `id`. If other filters are supported, they can be used 70 | with `and`, e.g `filter(resource, id == 1 or id == 2 and other_supported_filter == true)`, since those 71 | filters will be applied to each request. 72 | 73 | Expects the field to be available in the path template, e.g with `get_for` of `:id`, path should contain `:id`, e.g 74 | `/get/:id` or `/:id` 75 | """, 76 | entities: [ 77 | fields: [@field] 78 | ], 79 | args: [:action, :get_for] 80 | } 81 | 82 | @endpoints %Spark.Dsl.Section{ 83 | name: :endpoints, 84 | describe: "Contains the configuration for the endpoints used in each action", 85 | schema: [ 86 | base: [ 87 | type: :string, 88 | doc: "The base endpoint to which all relative urls provided will be appended." 89 | ] 90 | ], 91 | entities: [ 92 | @endpoint, 93 | @get_endpoint 94 | ] 95 | } 96 | 97 | @json_api_wrapper %Spark.Dsl.Section{ 98 | name: :json_api_wrapper, 99 | describe: "Contains the configuration for the json_api_wrapper data layer", 100 | sections: [ 101 | @fields, 102 | @endpoints 103 | ], 104 | imports: [ 105 | AshJsonApiWrapper.Paginator.Builtins 106 | ], 107 | schema: [ 108 | base_entity_path: [ 109 | type: :string, 110 | doc: """ 111 | Where in the response to find resulting entities. Can be overridden per endpoint. 112 | """ 113 | ], 114 | base_paginator: [ 115 | type: 116 | {:spark_behaviour, AshJsonApiWrapper.Paginator, AshJsonApiWrapper.Paginator.Builtins}, 117 | doc: """ 118 | A module implementing the `AshJSonApiWrapper.Paginator` behaviour, to allow scanning pages when reading. 119 | """ 120 | ], 121 | tesla: [ 122 | type: :atom, 123 | default: AshJsonApiWrapper.DefaultTesla, 124 | doc: """ 125 | The Tesla module to use. 126 | """ 127 | ] 128 | ] 129 | } 130 | 131 | require Logger 132 | 133 | use Spark.Dsl.Extension, 134 | sections: [@json_api_wrapper], 135 | transformers: [AshJsonApiWrapper.DataLayer.Transformers.SetEndpointDefaults] 136 | 137 | defmodule Query do 138 | @moduledoc false 139 | defstruct [ 140 | :domain, 141 | :context, 142 | :headers, 143 | :action, 144 | :limit, 145 | :offset, 146 | :filter, 147 | :runtime_filter, 148 | :path, 149 | :query_params, 150 | :body, 151 | :sort, 152 | :endpoint, 153 | :templates, 154 | :override_results 155 | ] 156 | end 157 | 158 | @behaviour Ash.DataLayer 159 | 160 | @impl true 161 | def can?(_, :create), do: true 162 | def can?(_, :boolean_filter), do: true 163 | def can?(_, :filter), do: true 164 | def can?(_, :limit), do: true 165 | def can?(_, :offset), do: true 166 | 167 | def can?( 168 | _, 169 | {:filter_expr, 170 | %Ash.Query.Operator.Eq{ 171 | left: %Ash.Query.Operator.Eq{left: %Ash.Query.Ref{}, right: %Ash.Query.Ref{}} 172 | }} 173 | ), 174 | do: false 175 | 176 | def can?( 177 | _, 178 | {:filter_expr, %Ash.Query.Operator.Eq{right: %Ash.Query.Ref{}}} 179 | ), 180 | do: true 181 | 182 | def can?( 183 | _, 184 | {:filter_expr, %Ash.Query.Operator.Eq{left: %Ash.Query.Ref{}}} 185 | ), 186 | do: true 187 | 188 | def can?( 189 | _, 190 | {:filter_expr, %Ash.Query.Operator.In{right: %Ash.Query.Ref{}}} 191 | ), 192 | do: false 193 | 194 | def can?( 195 | _, 196 | {:filter_expr, %Ash.Query.Operator.In{left: %Ash.Query.Ref{}, right: %Ash.Query.Ref{}}} 197 | ), 198 | do: false 199 | 200 | def can?( 201 | _, 202 | {:filter_expr, %Ash.Query.Operator.In{left: %Ash.Query.Ref{}}} 203 | ), 204 | do: true 205 | 206 | def can?(_, :nested_expressions), do: true 207 | 208 | def can?(_, :sort), do: true 209 | def can?(_, {:sort, _}), do: true 210 | def can?(_, _), do: false 211 | 212 | @impl true 213 | def resource_to_query(resource, domain \\ nil) do 214 | %Query{path: AshJsonApiWrapper.DataLayer.Info.endpoint_base(resource), domain: domain} 215 | end 216 | 217 | @impl true 218 | def filter(query, filter, resource) do 219 | if filter == false || match?(%Ash.Filter{expression: false}, filter) do 220 | %{query | override_results: []} 221 | else 222 | if filter == nil || filter == true || match?(%Ash.Filter{expression: nil}, filter) do 223 | {:ok, %{query | filter: filter}} 224 | else 225 | if query.action do 226 | case validate_filter(filter, resource, query.action) do 227 | {:ok, {endpoint, templates, instructions}, remaining_filter} -> 228 | {templates, instructions} = 229 | if templates && !Enum.empty?(templates) do 230 | {templates, instructions} 231 | else 232 | instructions = 233 | instructions 234 | |> Enum.reduce([], fn 235 | {:expand_set, field, values} = instruction, new_instructions -> 236 | if Enum.any?(new_instructions, fn 237 | {:expand_set, ^field, _other_values} -> 238 | true 239 | 240 | _ -> 241 | false 242 | end) do 243 | Enum.map(new_instructions, fn 244 | {:expand_set, ^field, other_values} -> 245 | {:expand_set, field, 246 | other_values 247 | |> MapSet.new() 248 | |> MapSet.intersection(MapSet.new(values)) 249 | |> MapSet.to_list()} 250 | 251 | other -> 252 | other 253 | end) 254 | else 255 | [instruction | new_instructions] 256 | end 257 | 258 | instruction, new_instructions -> 259 | [instruction | new_instructions] 260 | end) 261 | 262 | {expand_set, instructions} = 263 | Enum.split_with(instructions || [], fn 264 | {:expand_set, _, _} -> 265 | true 266 | 267 | _ -> 268 | false 269 | end) 270 | 271 | templates = 272 | expand_set 273 | |> Enum.at(0) 274 | |> case do 275 | nil -> 276 | nil 277 | 278 | {:expand_set, field, values} -> 279 | Enum.map(values, &{:set, field, &1}) 280 | end 281 | 282 | {templates, instructions} 283 | end 284 | 285 | new_query_params = 286 | Enum.reduce(instructions || [], query.query_params || %{}, fn 287 | {:set, field, value}, query -> 288 | field = 289 | field 290 | |> List.wrap() 291 | |> Enum.map(&to_string/1) 292 | 293 | AshJsonApiWrapper.Helpers.put_at_path(query, field, value) 294 | 295 | {:place_in_list, path, value}, query -> 296 | update_in!(query, path, [value], &[value | &1]) 297 | 298 | {:place_in_csv_list, path, value}, query -> 299 | update_in!(query, path, "#{value}", &"#{&1},#{value}") 300 | end) 301 | 302 | {:ok, 303 | %{ 304 | query 305 | | endpoint: endpoint, 306 | templates: templates, 307 | runtime_filter: remaining_filter, 308 | query_params: new_query_params 309 | }} 310 | 311 | {:error, error} -> 312 | {:error, error} 313 | end 314 | else 315 | {:ok, %{query | filter: filter}} 316 | end 317 | end 318 | end 319 | end 320 | 321 | @impl true 322 | def sort(query, sort, _resource) when sort in [nil, []] do 323 | {:ok, query} 324 | end 325 | 326 | def sort(query, sort, _resource) do 327 | endpoint = query.endpoint 328 | 329 | if endpoint.runtime_sort? do 330 | {:ok, %{query | sort: sort}} 331 | else 332 | {:error, "Sorting is not supported"} 333 | end 334 | end 335 | 336 | @impl true 337 | def set_context(resource, query, context) do 338 | params = context[:data_layer][:query_params] || %{} 339 | headers = Map.to_list(context[:data_layer][:headers] || %{}) 340 | 341 | action = context[:action] 342 | 343 | {:ok, 344 | %{ 345 | query 346 | | query_params: params, 347 | headers: headers, 348 | domain: query.domain, 349 | action: action, 350 | endpoint: AshJsonApiWrapper.DataLayer.Info.endpoint(resource, action.name), 351 | context: context 352 | }} 353 | end 354 | 355 | defp validate_filter(filter, resource, action) when filter in [nil, true] do 356 | {:ok, {AshJsonApiWrapper.DataLayer.Info.endpoint(resource, action.name), nil, []}, filter} 357 | end 358 | 359 | defp validate_filter(filter, resource, action) do 360 | case AshJsonApiWrapper.Filter.find_filter_that_uses_get_endpoint(filter, resource, action) do 361 | {:ok, {remaining_filter, get_endpoint, templates}} -> 362 | {:ok, {get_endpoint, templates, []}, remaining_filter} 363 | 364 | {:ok, nil} -> 365 | endpoint = AshJsonApiWrapper.DataLayer.Info.endpoint(resource, action.name) 366 | 367 | case filter_instructions(filter, resource, endpoint) do 368 | {:ok, instructions, remaining_filter} -> 369 | {:ok, {endpoint, nil, instructions}, remaining_filter} 370 | 371 | {:error, error} -> 372 | {:error, error} 373 | end 374 | 375 | {:error, error} -> 376 | {:error, error} 377 | end 378 | end 379 | 380 | defp filter_instructions(filter, resource, endpoint) do 381 | fields = 382 | endpoint.fields 383 | |> Enum.concat(AshJsonApiWrapper.DataLayer.Info.fields(resource)) 384 | |> Enum.uniq_by(& &1.name) 385 | |> List.wrap() 386 | |> Enum.filter(& &1.filter_handler) 387 | 388 | Enum.reduce_while(fields, {:ok, [], filter}, fn field, {:ok, instructions, filter} -> 389 | result = 390 | case field.filter_handler do 391 | :simple -> 392 | AshJsonApiWrapper.Filter.find_simple_filter(filter, field.name) 393 | 394 | {:simple, path} -> 395 | case AshJsonApiWrapper.Filter.find_simple_filter(filter, field.name) do 396 | {:ok, {remaining_filter, new_instructions}} -> 397 | field_name = field.name 398 | 399 | {:ok, 400 | {remaining_filter, 401 | Enum.map(new_instructions, fn 402 | {:set, ^field_name, value} -> 403 | {:set, path, value} 404 | 405 | {:expand_set, ^field_name, values} -> 406 | {:expand_set, path, values} 407 | 408 | other -> 409 | # don't think this is possible 410 | other 411 | end)}} 412 | 413 | other -> 414 | other 415 | end 416 | 417 | {:place_in_list, path} -> 418 | AshJsonApiWrapper.Filter.find_place_in_list_filter( 419 | filter, 420 | field.name, 421 | path, 422 | :place_in_list 423 | ) 424 | 425 | {:place_in_csv_list, path} -> 426 | AshJsonApiWrapper.Filter.find_place_in_list_filter( 427 | filter, 428 | field.name, 429 | path, 430 | :place_in_csv_list 431 | ) 432 | end 433 | 434 | case result do 435 | {:ok, nil} -> 436 | {:cont, {:ok, instructions, filter}} 437 | 438 | {:ok, {remaining_filter, new_instructions}} -> 439 | {:cont, {:ok, new_instructions ++ instructions, remaining_filter}} 440 | end 441 | end) 442 | |> case do 443 | {:ok, instructions, nil} -> 444 | {:ok, instructions, nil} 445 | 446 | {:ok, instructions, remaining_filter} -> 447 | {:ok, instructions, remaining_filter} 448 | 449 | {:error, error} -> 450 | {:error, error} 451 | end 452 | end 453 | 454 | @impl true 455 | def limit(query, limit, _resource) do 456 | {:ok, %{query | limit: limit}} 457 | end 458 | 459 | @impl true 460 | def offset(query, offset, _resource) do 461 | {:ok, %{query | offset: offset}} 462 | end 463 | 464 | @impl true 465 | def create(resource, changeset) do 466 | endpoint = AshJsonApiWrapper.DataLayer.Info.endpoint(resource, changeset.action.name) 467 | 468 | base = 469 | case endpoint.fields_in || :body do 470 | :body -> 471 | changeset.context[:data_layer][:body] || %{} 472 | 473 | :params -> 474 | changeset.context[:data_layer][:query_params] || %{} 475 | end 476 | 477 | {:ok, with_attrs} = 478 | changeset.attributes 479 | |> Kernel.||(%{}) 480 | |> Enum.reduce_while({:ok, base}, fn {key, value}, {:ok, acc} -> 481 | attribute = Ash.Resource.Info.attribute(resource, key) 482 | field = AshJsonApiWrapper.DataLayer.Info.field(resource, attribute.name) 483 | 484 | case Ash.Type.dump_to_embedded( 485 | attribute.type, 486 | value, 487 | attribute.constraints 488 | ) do 489 | {:ok, dumped} -> 490 | path = 491 | if field && field.write_path do 492 | field.write_path 493 | else 494 | [to_string(attribute.name)] 495 | end 496 | 497 | path = 498 | if endpoint.write_entity_path do 499 | endpoint.write_entity_path ++ path 500 | else 501 | path 502 | end 503 | 504 | {:cont, {:ok, put_in!(acc, path, dumped)}} 505 | 506 | :error -> 507 | {:halt, 508 | {:error, 509 | Ash.Error.Changes.InvalidAttribute.exception( 510 | field: attribute.name, 511 | message: "Could not be dumped to embedded" 512 | )}} 513 | end 514 | end) 515 | 516 | {body, params} = 517 | case endpoint.fields_in do 518 | :params -> 519 | {changeset.context[:data_layer][:body] || %{}, with_attrs} 520 | 521 | :body -> 522 | {with_attrs, changeset.context[:data_layer][:query_params] || %{}} 523 | end 524 | 525 | path = endpoint.path || AshJsonApiWrapper.DataLayer.Info.endpoint_base(resource) 526 | headers = [{"Content-Type", "application/json"}, {"Accept", "application/json"}] 527 | 528 | with {:ok, %{status: status} = response} when status >= 200 and status < 300 <- 529 | AshJsonApiWrapper.DataLayer.Info.tesla(resource).get(path, 530 | body: body, 531 | query: params, 532 | headers: headers 533 | ), 534 | {:ok, body} <- Jason.decode(response.body), 535 | {:ok, entities} <- get_entities(body, endpoint, resource), 536 | {:ok, processed} <- 537 | process_entities(entities, resource, endpoint) do 538 | {:ok, Enum.at(processed, 0)} 539 | else 540 | {:ok, %{status: status} = response} -> 541 | # TODO: add method/query params 542 | {:error, 543 | "Received status code #{status} from GET #{path}. Response: #{inspect(response)}"} 544 | 545 | other -> 546 | other 547 | end 548 | end 549 | 550 | defp put_in!(body, [key], value) do 551 | Map.put(body || %{}, key, value) 552 | end 553 | 554 | defp put_in!(body, [first | rest], value) do 555 | body 556 | |> Map.put_new(first, %{}) 557 | |> Map.update!(first, &put_in!(&1, rest, value)) 558 | end 559 | 560 | defp update_in!(body, [key], default, func) do 561 | body 562 | |> Kernel.||(%{}) 563 | |> Map.put_new(key, default) 564 | |> Map.update!(key, func) 565 | end 566 | 567 | defp update_in!(body, [first | rest], default, func) do 568 | body 569 | |> Map.put_new(first, %{}) 570 | |> Map.update!(first, &update_in!(&1, rest, default, func)) 571 | end 572 | 573 | @impl true 574 | def run_query(query, resource, overridden? \\ false) 575 | 576 | def run_query(%{override_results: results} = query, _resource, _overriden) 577 | when not is_nil(results) do 578 | do_sort({:ok, results}, query) 579 | end 580 | 581 | def run_query(query, resource, overridden?) do 582 | endpoint = 583 | query.endpoint || AshJsonApiWrapper.DataLayer.Info.endpoint(resource, query.action.name) 584 | 585 | query = 586 | if overridden? do 587 | query 588 | else 589 | %{query | path: endpoint.path} 590 | end 591 | 592 | if query.templates do 593 | query.templates 594 | |> Enum.uniq() 595 | |> Task.async_stream( 596 | fn template -> 597 | query 598 | |> fill_template(template) 599 | |> Map.put(:templates, nil) 600 | |> run_query(resource, true) 601 | end, 602 | timeout: :infinity 603 | ) 604 | |> Enum.reduce_while( 605 | {:ok, []}, 606 | fn 607 | {:ok, {:ok, results}}, {:ok, all_results} -> 608 | {:cont, {:ok, results ++ all_results}} 609 | 610 | {:ok, {:error, error}}, _ -> 611 | {:halt, {:error, error}} 612 | 613 | {:exit, reason}, _ -> 614 | {:error, "Request process exited with #{inspect(reason)}"} 615 | end 616 | ) 617 | else 618 | query = 619 | if query.limit do 620 | if query.offset && query.offset != 0 do 621 | Logger.warning( 622 | "ash_json_api_wrapper does not support limits with offsets yet, and so they will both be applied after." 623 | ) 624 | end 625 | 626 | case endpoint.limit_with do 627 | {:param, param} -> 628 | %{ 629 | query 630 | | query_params: Map.put(query.query_params || %{}, param, query.limit) 631 | } 632 | 633 | _ -> 634 | query 635 | end 636 | else 637 | query 638 | end 639 | 640 | query = 641 | if endpoint.paginator do 642 | %{paginator: {module, opts}} = endpoint 643 | {:ok, instructions} = module.start(opts) 644 | apply_instructions(query, instructions) 645 | else 646 | query 647 | end 648 | 649 | with {:ok, %{status: status} = response} when status >= 200 and status < 300 <- 650 | make_request(resource, query), 651 | {:ok, body} <- Jason.decode(response.body), 652 | {:ok, entities} <- get_entities(body, endpoint, resource, paginate_with: query) do 653 | entities 654 | |> limit_offset(query) 655 | |> process_entities(resource, endpoint) 656 | else 657 | {:ok, %{status: status} = response} -> 658 | # TODO: more info here 659 | {:error, 660 | "Received status code #{status} from #{query.path}. Response: #{inspect(response)}"} 661 | 662 | other -> 663 | other 664 | end 665 | end 666 | |> do_sort(query) 667 | |> runtime_filter(query) 668 | end 669 | 670 | defp runtime_filter({:ok, results}, query) do 671 | if is_nil(query.runtime_filter) do 672 | {:ok, results} 673 | else 674 | Ash.Filter.Runtime.filter_matches(query.domain, results, query.runtime_filter) 675 | end 676 | end 677 | 678 | defp runtime_filter(other, _) do 679 | other 680 | end 681 | 682 | defp do_sort({:ok, results}, %{sort: sort}) when sort not in [nil, []] do 683 | Ash.Sort.runtime_sort(results, sort, []) 684 | end 685 | 686 | defp do_sort(other, _), do: other 687 | 688 | defp fill_template(query, template) do 689 | template 690 | |> List.wrap() 691 | |> Enum.reduce(query, fn 692 | {key, replacement}, query -> 693 | %{ 694 | query 695 | | path: String.replace(query.path, ":#{key}", to_string(replacement)) 696 | } 697 | 698 | {:set, key, value}, query -> 699 | key = 700 | key 701 | |> List.wrap() 702 | |> Enum.map(&to_string/1) 703 | 704 | %{ 705 | query 706 | | query_params: AshJsonApiWrapper.Helpers.put_at_path(query.query_params, key, value) 707 | } 708 | end) 709 | end 710 | 711 | defp limit_offset(results, %Query{limit: limit, offset: offset}) do 712 | results = 713 | if offset do 714 | Enum.drop(results, offset) 715 | else 716 | results 717 | end 718 | 719 | if limit do 720 | Enum.take(results, limit) 721 | else 722 | results 723 | end 724 | end 725 | 726 | defp make_request(resource, query) do 727 | # log_send(path, query) 728 | # IO.inspect(query.query_params) 729 | 730 | AshJsonApiWrapper.DataLayer.Info.tesla(resource).get(query.path, 731 | body: query.body, 732 | query: query.query_params 733 | ) 734 | 735 | # |> log_resp(path, query) 736 | end 737 | 738 | # defp log_send(request) do 739 | # Logger.debug("Sending request: #{inspect(request)}") 740 | # request 741 | # end 742 | 743 | # defp log_resp(response) do 744 | # Logger.debug("Received response: #{inspect(response)}") 745 | # response 746 | # end 747 | 748 | defp process_entities(entities, resource, endpoint) do 749 | Enum.reduce_while(entities, {:ok, []}, fn entity, {:ok, entities} -> 750 | case process_entity(entity, resource, endpoint) do 751 | {:ok, entity} -> {:cont, {:ok, [entity | entities]}} 752 | {:error, error} -> {:halt, {:error, error}} 753 | end 754 | end) 755 | |> case do 756 | {:ok, entities} -> {:ok, Enum.reverse(entities)} 757 | {:error, error} -> {:error, error} 758 | end 759 | end 760 | 761 | defp process_entity(entity, resource, endpoint) do 762 | resource 763 | |> Ash.Resource.Info.attributes() 764 | |> Enum.reduce_while( 765 | {:ok, 766 | struct(resource, 767 | __meta__: %Ecto.Schema.Metadata{ 768 | state: :loaded 769 | } 770 | )}, 771 | fn attr, {:ok, record} -> 772 | case get_field(entity, attr, resource, endpoint) do 773 | {:ok, value} -> 774 | {:cont, {:ok, Map.put(record, attr.name, value)}} 775 | 776 | {:error, error} -> 777 | {:halt, {:error, error}} 778 | end 779 | end 780 | ) 781 | end 782 | 783 | defp get_field(entity, attr, resource, endpoint) do 784 | raw_value = get_raw_value(entity, attr, resource, endpoint) 785 | 786 | case Ash.Type.cast_stored(attr.type, raw_value, attr.constraints) do 787 | {:ok, value} -> 788 | {:ok, value} 789 | 790 | _ -> 791 | {:error, 792 | AshJsonApiWrapper.Errors.InvalidData.exception(field: attr.name, value: raw_value)} 793 | end 794 | end 795 | 796 | defp get_raw_value(entity, attr, resource, endpoint) do 797 | case get_field(resource, endpoint, attr.name) do 798 | %{path: ""} -> 799 | entity 800 | 801 | %{path: path} when not is_nil(path) -> 802 | case ExJSONPath.eval(entity, path) do 803 | {:ok, [value | _]} -> 804 | value 805 | 806 | _ -> 807 | nil 808 | end 809 | 810 | _ -> 811 | Map.get(entity, to_string(attr.name)) 812 | end 813 | end 814 | 815 | defp get_entities(body, endpoint, resource, opts \\ []) do 816 | if opts[:paginate_with] && endpoint.paginator do 817 | with {:ok, entities} <- 818 | get_entities(body, endpoint, resource, Keyword.delete(opts, :paginate_with)), 819 | {:ok, bodies} <- 820 | get_all_bodies( 821 | body, 822 | endpoint, 823 | resource, 824 | opts[:paginate_with], 825 | &get_entities(&1, endpoint, resource, Keyword.delete(opts, :paginate_with)), 826 | [entities] 827 | ) do 828 | {:ok, bodies |> Enum.reverse() |> List.flatten()} 829 | end 830 | else 831 | case endpoint.entity_path do 832 | nil -> 833 | {:ok, List.wrap(body)} 834 | 835 | path -> 836 | case ExJSONPath.eval(body, path) do 837 | {:ok, [entities | _]} -> 838 | {:ok, List.wrap(entities)} 839 | 840 | {:ok, _} -> 841 | {:ok, []} 842 | 843 | {:error, error} -> 844 | {:error, error} 845 | end 846 | end 847 | end 848 | end 849 | 850 | defp get_all_bodies( 851 | body, 852 | %{paginator: {module, opts}} = endpoint, 853 | resource, 854 | paginate_with, 855 | entity_callback, 856 | bodies 857 | ) do 858 | case module.continue(body, Enum.at(bodies, 0), opts) do 859 | :halt -> 860 | {:ok, bodies} 861 | 862 | {:ok, instructions} -> 863 | query = apply_instructions(paginate_with, instructions) 864 | 865 | case make_request(resource, query) do 866 | {:ok, %{status: status} = response} when status >= 200 and status < 300 -> 867 | with {:ok, new_body} <- Jason.decode(response.body), 868 | {:ok, entities} <- entity_callback.(new_body) do 869 | get_all_bodies(new_body, endpoint, resource, paginate_with, entity_callback, [ 870 | entities | bodies 871 | ]) 872 | end 873 | 874 | {:ok, %{status: status} = response} -> 875 | # TODO: more info 876 | {:error, 877 | "Received status code #{status} in #{query.path}. Response: #{inspect(response)}"} 878 | 879 | {:error, error} -> 880 | {:error, error} 881 | end 882 | end 883 | end 884 | 885 | defp apply_instructions(query, instructions) do 886 | query 887 | |> apply_params(instructions) 888 | |> apply_headers(instructions) 889 | end 890 | 891 | defp apply_params(query, %{params: params}) when is_map(params) do 892 | %{query | query_params: Ash.Helpers.deep_merge_maps(query.query_params || %{}, params)} 893 | end 894 | 895 | defp apply_params(query, _), do: query 896 | 897 | defp apply_headers(query, %{headers: headers}) when is_map(headers) do 898 | %{ 899 | query 900 | | headers: 901 | query.headers 902 | |> Kernel.||(%{}) 903 | |> Map.new() 904 | |> Map.merge(headers) 905 | |> Map.to_list() 906 | } 907 | end 908 | 909 | defp apply_headers(query, _), do: query 910 | 911 | defp get_field(resource, endpoint, field) do 912 | Enum.find(endpoint.fields || [], &(&1.name == field)) || 913 | Enum.find(AshJsonApiWrapper.DataLayer.Info.fields(resource), &(&1.name == field)) 914 | end 915 | end 916 | -------------------------------------------------------------------------------- /test/support/pet_store.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.3", 3 | "info": { 4 | "title": "Swagger Petstore - OpenAPI 3.0", 5 | "description": "This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about\nSwagger at [https://swagger.io](https://swagger.io). In the third iteration of the pet store, we've switched to the design first approach!\nYou can now help us improve the API whether it's by making changes to the definition itself or to the code.\nThat way, with time, we can improve the API in general, and expose some of the new features in OAS3.\n\n_If you're looking for the Swagger 2.0/OAS 2.0 version of Petstore, then click [here](https://editor.swagger.io/?url=https://petstore.swagger.io/v2/swagger.yaml). Alternatively, you can load via the `Edit > Load Petstore OAS 2.0` menu option!_\n\nSome useful links:\n- [The Pet Store repository](https://github.com/swagger-api/swagger-petstore)\n- [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml)", 6 | "termsOfService": "http://swagger.io/terms/", 7 | "contact": { 8 | "email": "apiteam@swagger.io" 9 | }, 10 | "license": { 11 | "name": "Apache 2.0", 12 | "url": "http://www.apache.org/licenses/LICENSE-2.0.html" 13 | }, 14 | "version": "1.0.11" 15 | }, 16 | "externalDocs": { 17 | "description": "Find out more about Swagger", 18 | "url": "http://swagger.io" 19 | }, 20 | "servers": [ 21 | { 22 | "url": "https://petstore3.swagger.io/api/v3" 23 | } 24 | ], 25 | "tags": [ 26 | { 27 | "name": "pet", 28 | "description": "Everything about your Pets", 29 | "externalDocs": { 30 | "description": "Find out more", 31 | "url": "http://swagger.io" 32 | } 33 | }, 34 | { 35 | "name": "store", 36 | "description": "Access to Petstore orders", 37 | "externalDocs": { 38 | "description": "Find out more about our store", 39 | "url": "http://swagger.io" 40 | } 41 | }, 42 | { 43 | "name": "user", 44 | "description": "Operations about user" 45 | } 46 | ], 47 | "paths": { 48 | "/pet": { 49 | "put": { 50 | "tags": ["pet"], 51 | "summary": "Update an existing pet", 52 | "description": "Update an existing pet by Id", 53 | "operationId": "updatePet", 54 | "requestBody": { 55 | "description": "Update an existent pet in the store", 56 | "content": { 57 | "application/json": { 58 | "schema": { 59 | "$ref": "#/components/schemas/Pet" 60 | } 61 | }, 62 | "application/xml": { 63 | "schema": { 64 | "$ref": "#/components/schemas/Pet" 65 | } 66 | }, 67 | "application/x-www-form-urlencoded": { 68 | "schema": { 69 | "$ref": "#/components/schemas/Pet" 70 | } 71 | } 72 | }, 73 | "required": true 74 | }, 75 | "responses": { 76 | "200": { 77 | "description": "Successful operation", 78 | "content": { 79 | "application/json": { 80 | "schema": { 81 | "$ref": "#/components/schemas/Pet" 82 | } 83 | }, 84 | "application/xml": { 85 | "schema": { 86 | "$ref": "#/components/schemas/Pet" 87 | } 88 | } 89 | } 90 | }, 91 | "400": { 92 | "description": "Invalid ID supplied" 93 | }, 94 | "404": { 95 | "description": "Pet not found" 96 | }, 97 | "405": { 98 | "description": "Validation exception" 99 | } 100 | }, 101 | "security": [ 102 | { 103 | "petstore_auth": ["write:pets", "read:pets"] 104 | } 105 | ] 106 | }, 107 | "post": { 108 | "tags": ["pet"], 109 | "summary": "Add a new pet to the store", 110 | "description": "Add a new pet to the store", 111 | "operationId": "addPet", 112 | "requestBody": { 113 | "description": "Create a new pet in the store", 114 | "content": { 115 | "application/json": { 116 | "schema": { 117 | "$ref": "#/components/schemas/Pet" 118 | } 119 | }, 120 | "application/xml": { 121 | "schema": { 122 | "$ref": "#/components/schemas/Pet" 123 | } 124 | }, 125 | "application/x-www-form-urlencoded": { 126 | "schema": { 127 | "$ref": "#/components/schemas/Pet" 128 | } 129 | } 130 | }, 131 | "required": true 132 | }, 133 | "responses": { 134 | "200": { 135 | "description": "Successful operation", 136 | "content": { 137 | "application/json": { 138 | "schema": { 139 | "$ref": "#/components/schemas/Pet" 140 | } 141 | }, 142 | "application/xml": { 143 | "schema": { 144 | "$ref": "#/components/schemas/Pet" 145 | } 146 | } 147 | } 148 | }, 149 | "405": { 150 | "description": "Invalid input" 151 | } 152 | }, 153 | "security": [ 154 | { 155 | "petstore_auth": ["write:pets", "read:pets"] 156 | } 157 | ] 158 | } 159 | }, 160 | "/pet/findByStatus": { 161 | "get": { 162 | "tags": ["pet"], 163 | "summary": "Finds Pets by status", 164 | "description": "Multiple status values can be provided with comma separated strings", 165 | "operationId": "findPetsByStatus", 166 | "parameters": [ 167 | { 168 | "name": "status", 169 | "in": "query", 170 | "description": "Status values that need to be considered for filter", 171 | "required": false, 172 | "explode": true, 173 | "schema": { 174 | "type": "string", 175 | "default": "available", 176 | "enum": ["available", "pending", "sold"] 177 | } 178 | } 179 | ], 180 | "responses": { 181 | "200": { 182 | "description": "successful operation", 183 | "content": { 184 | "application/json": { 185 | "schema": { 186 | "type": "array", 187 | "items": { 188 | "$ref": "#/components/schemas/Pet" 189 | } 190 | } 191 | }, 192 | "application/xml": { 193 | "schema": { 194 | "type": "array", 195 | "items": { 196 | "$ref": "#/components/schemas/Pet" 197 | } 198 | } 199 | } 200 | } 201 | }, 202 | "400": { 203 | "description": "Invalid status value" 204 | } 205 | }, 206 | "security": [ 207 | { 208 | "petstore_auth": ["write:pets", "read:pets"] 209 | } 210 | ] 211 | } 212 | }, 213 | "/pet/findByTags": { 214 | "get": { 215 | "tags": ["pet"], 216 | "summary": "Finds Pets by tags", 217 | "description": "Multiple tags can be provided with comma separated strings. Use tag1, tag2, tag3 for testing.", 218 | "operationId": "findPetsByTags", 219 | "parameters": [ 220 | { 221 | "name": "tags", 222 | "in": "query", 223 | "description": "Tags to filter by", 224 | "required": false, 225 | "explode": true, 226 | "schema": { 227 | "type": "array", 228 | "items": { 229 | "type": "string" 230 | } 231 | } 232 | } 233 | ], 234 | "responses": { 235 | "200": { 236 | "description": "successful operation", 237 | "content": { 238 | "application/json": { 239 | "schema": { 240 | "type": "array", 241 | "items": { 242 | "$ref": "#/components/schemas/Pet" 243 | } 244 | } 245 | }, 246 | "application/xml": { 247 | "schema": { 248 | "type": "array", 249 | "items": { 250 | "$ref": "#/components/schemas/Pet" 251 | } 252 | } 253 | } 254 | } 255 | }, 256 | "400": { 257 | "description": "Invalid tag value" 258 | } 259 | }, 260 | "security": [ 261 | { 262 | "petstore_auth": ["write:pets", "read:pets"] 263 | } 264 | ] 265 | } 266 | }, 267 | "/pet/{petId}": { 268 | "get": { 269 | "tags": ["pet"], 270 | "summary": "Find pet by ID", 271 | "description": "Returns a single pet", 272 | "operationId": "getPetById", 273 | "parameters": [ 274 | { 275 | "name": "petId", 276 | "in": "path", 277 | "description": "ID of pet to return", 278 | "required": true, 279 | "schema": { 280 | "type": "integer", 281 | "format": "int64" 282 | } 283 | } 284 | ], 285 | "responses": { 286 | "200": { 287 | "description": "successful operation", 288 | "content": { 289 | "application/json": { 290 | "schema": { 291 | "$ref": "#/components/schemas/Pet" 292 | } 293 | }, 294 | "application/xml": { 295 | "schema": { 296 | "$ref": "#/components/schemas/Pet" 297 | } 298 | } 299 | } 300 | }, 301 | "400": { 302 | "description": "Invalid ID supplied" 303 | }, 304 | "404": { 305 | "description": "Pet not found" 306 | } 307 | }, 308 | "security": [ 309 | { 310 | "api_key": [] 311 | }, 312 | { 313 | "petstore_auth": ["write:pets", "read:pets"] 314 | } 315 | ] 316 | }, 317 | "post": { 318 | "tags": ["pet"], 319 | "summary": "Updates a pet in the store with form data", 320 | "description": "", 321 | "operationId": "updatePetWithForm", 322 | "parameters": [ 323 | { 324 | "name": "petId", 325 | "in": "path", 326 | "description": "ID of pet that needs to be updated", 327 | "required": true, 328 | "schema": { 329 | "type": "integer", 330 | "format": "int64" 331 | } 332 | }, 333 | { 334 | "name": "name", 335 | "in": "query", 336 | "description": "Name of pet that needs to be updated", 337 | "schema": { 338 | "type": "string" 339 | } 340 | }, 341 | { 342 | "name": "status", 343 | "in": "query", 344 | "description": "Status of pet that needs to be updated", 345 | "schema": { 346 | "type": "string" 347 | } 348 | } 349 | ], 350 | "responses": { 351 | "405": { 352 | "description": "Invalid input" 353 | } 354 | }, 355 | "security": [ 356 | { 357 | "petstore_auth": ["write:pets", "read:pets"] 358 | } 359 | ] 360 | }, 361 | "delete": { 362 | "tags": ["pet"], 363 | "summary": "Deletes a pet", 364 | "description": "delete a pet", 365 | "operationId": "deletePet", 366 | "parameters": [ 367 | { 368 | "name": "api_key", 369 | "in": "header", 370 | "description": "", 371 | "required": false, 372 | "schema": { 373 | "type": "string" 374 | } 375 | }, 376 | { 377 | "name": "petId", 378 | "in": "path", 379 | "description": "Pet id to delete", 380 | "required": true, 381 | "schema": { 382 | "type": "integer", 383 | "format": "int64" 384 | } 385 | } 386 | ], 387 | "responses": { 388 | "400": { 389 | "description": "Invalid pet value" 390 | } 391 | }, 392 | "security": [ 393 | { 394 | "petstore_auth": ["write:pets", "read:pets"] 395 | } 396 | ] 397 | } 398 | }, 399 | "/pet/{petId}/uploadImage": { 400 | "post": { 401 | "tags": ["pet"], 402 | "summary": "uploads an image", 403 | "description": "", 404 | "operationId": "uploadFile", 405 | "parameters": [ 406 | { 407 | "name": "petId", 408 | "in": "path", 409 | "description": "ID of pet to update", 410 | "required": true, 411 | "schema": { 412 | "type": "integer", 413 | "format": "int64" 414 | } 415 | }, 416 | { 417 | "name": "additionalMetadata", 418 | "in": "query", 419 | "description": "Additional Metadata", 420 | "required": false, 421 | "schema": { 422 | "type": "string" 423 | } 424 | } 425 | ], 426 | "requestBody": { 427 | "content": { 428 | "application/octet-stream": { 429 | "schema": { 430 | "type": "string", 431 | "format": "binary" 432 | } 433 | } 434 | } 435 | }, 436 | "responses": { 437 | "200": { 438 | "description": "successful operation", 439 | "content": { 440 | "application/json": { 441 | "schema": { 442 | "$ref": "#/components/schemas/ApiResponse" 443 | } 444 | } 445 | } 446 | } 447 | }, 448 | "security": [ 449 | { 450 | "petstore_auth": ["write:pets", "read:pets"] 451 | } 452 | ] 453 | } 454 | }, 455 | "/store/inventory": { 456 | "get": { 457 | "tags": ["store"], 458 | "summary": "Returns pet inventories by status", 459 | "description": "Returns a map of status codes to quantities", 460 | "operationId": "getInventory", 461 | "responses": { 462 | "200": { 463 | "description": "successful operation", 464 | "content": { 465 | "application/json": { 466 | "schema": { 467 | "type": "object", 468 | "additionalProperties": { 469 | "type": "integer", 470 | "format": "int32" 471 | } 472 | } 473 | } 474 | } 475 | } 476 | }, 477 | "security": [ 478 | { 479 | "api_key": [] 480 | } 481 | ] 482 | } 483 | }, 484 | "/store/order": { 485 | "post": { 486 | "tags": ["store"], 487 | "summary": "Place an order for a pet", 488 | "description": "Place a new order in the store", 489 | "operationId": "placeOrder", 490 | "requestBody": { 491 | "content": { 492 | "application/json": { 493 | "schema": { 494 | "$ref": "#/components/schemas/Order" 495 | } 496 | }, 497 | "application/xml": { 498 | "schema": { 499 | "$ref": "#/components/schemas/Order" 500 | } 501 | }, 502 | "application/x-www-form-urlencoded": { 503 | "schema": { 504 | "$ref": "#/components/schemas/Order" 505 | } 506 | } 507 | } 508 | }, 509 | "responses": { 510 | "200": { 511 | "description": "successful operation", 512 | "content": { 513 | "application/json": { 514 | "schema": { 515 | "$ref": "#/components/schemas/Order" 516 | } 517 | } 518 | } 519 | }, 520 | "405": { 521 | "description": "Invalid input" 522 | } 523 | } 524 | } 525 | }, 526 | "/store/order/{orderId}": { 527 | "get": { 528 | "tags": ["store"], 529 | "summary": "Find purchase order by ID", 530 | "description": "For valid response try integer IDs with value <= 5 or > 10. Other values will generate exceptions.", 531 | "operationId": "getOrderById", 532 | "parameters": [ 533 | { 534 | "name": "orderId", 535 | "in": "path", 536 | "description": "ID of order that needs to be fetched", 537 | "required": true, 538 | "schema": { 539 | "type": "integer", 540 | "format": "int64" 541 | } 542 | } 543 | ], 544 | "responses": { 545 | "200": { 546 | "description": "successful operation", 547 | "content": { 548 | "application/json": { 549 | "schema": { 550 | "$ref": "#/components/schemas/Order" 551 | } 552 | }, 553 | "application/xml": { 554 | "schema": { 555 | "$ref": "#/components/schemas/Order" 556 | } 557 | } 558 | } 559 | }, 560 | "400": { 561 | "description": "Invalid ID supplied" 562 | }, 563 | "404": { 564 | "description": "Order not found" 565 | } 566 | } 567 | }, 568 | "delete": { 569 | "tags": ["store"], 570 | "summary": "Delete purchase order by ID", 571 | "description": "For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors", 572 | "operationId": "deleteOrder", 573 | "parameters": [ 574 | { 575 | "name": "orderId", 576 | "in": "path", 577 | "description": "ID of the order that needs to be deleted", 578 | "required": true, 579 | "schema": { 580 | "type": "integer", 581 | "format": "int64" 582 | } 583 | } 584 | ], 585 | "responses": { 586 | "400": { 587 | "description": "Invalid ID supplied" 588 | }, 589 | "404": { 590 | "description": "Order not found" 591 | } 592 | } 593 | } 594 | }, 595 | "/user": { 596 | "post": { 597 | "tags": ["user"], 598 | "summary": "Create user", 599 | "description": "This can only be done by the logged in user.", 600 | "operationId": "createUser", 601 | "requestBody": { 602 | "description": "Created user object", 603 | "content": { 604 | "application/json": { 605 | "schema": { 606 | "$ref": "#/components/schemas/User" 607 | } 608 | }, 609 | "application/xml": { 610 | "schema": { 611 | "$ref": "#/components/schemas/User" 612 | } 613 | }, 614 | "application/x-www-form-urlencoded": { 615 | "schema": { 616 | "$ref": "#/components/schemas/User" 617 | } 618 | } 619 | } 620 | }, 621 | "responses": { 622 | "default": { 623 | "description": "successful operation", 624 | "content": { 625 | "application/json": { 626 | "schema": { 627 | "$ref": "#/components/schemas/User" 628 | } 629 | }, 630 | "application/xml": { 631 | "schema": { 632 | "$ref": "#/components/schemas/User" 633 | } 634 | } 635 | } 636 | } 637 | } 638 | } 639 | }, 640 | "/user/createWithList": { 641 | "post": { 642 | "tags": ["user"], 643 | "summary": "Creates list of users with given input array", 644 | "description": "Creates list of users with given input array", 645 | "operationId": "createUsersWithListInput", 646 | "requestBody": { 647 | "content": { 648 | "application/json": { 649 | "schema": { 650 | "type": "array", 651 | "items": { 652 | "$ref": "#/components/schemas/User" 653 | } 654 | } 655 | } 656 | } 657 | }, 658 | "responses": { 659 | "200": { 660 | "description": "Successful operation", 661 | "content": { 662 | "application/json": { 663 | "schema": { 664 | "$ref": "#/components/schemas/User" 665 | } 666 | }, 667 | "application/xml": { 668 | "schema": { 669 | "$ref": "#/components/schemas/User" 670 | } 671 | } 672 | } 673 | }, 674 | "default": { 675 | "description": "successful operation" 676 | } 677 | } 678 | } 679 | }, 680 | "/user/login": { 681 | "get": { 682 | "tags": ["user"], 683 | "summary": "Logs user into the system", 684 | "description": "", 685 | "operationId": "loginUser", 686 | "parameters": [ 687 | { 688 | "name": "username", 689 | "in": "query", 690 | "description": "The user name for login", 691 | "required": false, 692 | "schema": { 693 | "type": "string" 694 | } 695 | }, 696 | { 697 | "name": "password", 698 | "in": "query", 699 | "description": "The password for login in clear text", 700 | "required": false, 701 | "schema": { 702 | "type": "string" 703 | } 704 | } 705 | ], 706 | "responses": { 707 | "200": { 708 | "description": "successful operation", 709 | "headers": { 710 | "X-Rate-Limit": { 711 | "description": "calls per hour allowed by the user", 712 | "schema": { 713 | "type": "integer", 714 | "format": "int32" 715 | } 716 | }, 717 | "X-Expires-After": { 718 | "description": "date in UTC when token expires", 719 | "schema": { 720 | "type": "string", 721 | "format": "date-time" 722 | } 723 | } 724 | }, 725 | "content": { 726 | "application/xml": { 727 | "schema": { 728 | "type": "string" 729 | } 730 | }, 731 | "application/json": { 732 | "schema": { 733 | "type": "string" 734 | } 735 | } 736 | } 737 | }, 738 | "400": { 739 | "description": "Invalid username/password supplied" 740 | } 741 | } 742 | } 743 | }, 744 | "/user/logout": { 745 | "get": { 746 | "tags": ["user"], 747 | "summary": "Logs out current logged in user session", 748 | "description": "", 749 | "operationId": "logoutUser", 750 | "parameters": [], 751 | "responses": { 752 | "default": { 753 | "description": "successful operation" 754 | } 755 | } 756 | } 757 | }, 758 | "/user/{username}": { 759 | "get": { 760 | "tags": ["user"], 761 | "summary": "Get user by user name", 762 | "description": "", 763 | "operationId": "getUserByName", 764 | "parameters": [ 765 | { 766 | "name": "username", 767 | "in": "path", 768 | "description": "The name that needs to be fetched. Use user1 for testing. ", 769 | "required": true, 770 | "schema": { 771 | "type": "string" 772 | } 773 | } 774 | ], 775 | "responses": { 776 | "200": { 777 | "description": "successful operation", 778 | "content": { 779 | "application/json": { 780 | "schema": { 781 | "$ref": "#/components/schemas/User" 782 | } 783 | }, 784 | "application/xml": { 785 | "schema": { 786 | "$ref": "#/components/schemas/User" 787 | } 788 | } 789 | } 790 | }, 791 | "400": { 792 | "description": "Invalid username supplied" 793 | }, 794 | "404": { 795 | "description": "User not found" 796 | } 797 | } 798 | }, 799 | "put": { 800 | "tags": ["user"], 801 | "summary": "Update user", 802 | "description": "This can only be done by the logged in user.", 803 | "operationId": "updateUser", 804 | "parameters": [ 805 | { 806 | "name": "username", 807 | "in": "path", 808 | "description": "name that need to be deleted", 809 | "required": true, 810 | "schema": { 811 | "type": "string" 812 | } 813 | } 814 | ], 815 | "requestBody": { 816 | "description": "Update an existent user in the store", 817 | "content": { 818 | "application/json": { 819 | "schema": { 820 | "$ref": "#/components/schemas/User" 821 | } 822 | }, 823 | "application/xml": { 824 | "schema": { 825 | "$ref": "#/components/schemas/User" 826 | } 827 | }, 828 | "application/x-www-form-urlencoded": { 829 | "schema": { 830 | "$ref": "#/components/schemas/User" 831 | } 832 | } 833 | } 834 | }, 835 | "responses": { 836 | "default": { 837 | "description": "successful operation" 838 | } 839 | } 840 | }, 841 | "delete": { 842 | "tags": ["user"], 843 | "summary": "Delete user", 844 | "description": "This can only be done by the logged in user.", 845 | "operationId": "deleteUser", 846 | "parameters": [ 847 | { 848 | "name": "username", 849 | "in": "path", 850 | "description": "The name that needs to be deleted", 851 | "required": true, 852 | "schema": { 853 | "type": "string" 854 | } 855 | } 856 | ], 857 | "responses": { 858 | "400": { 859 | "description": "Invalid username supplied" 860 | }, 861 | "404": { 862 | "description": "User not found" 863 | } 864 | } 865 | } 866 | } 867 | }, 868 | "components": { 869 | "schemas": { 870 | "Order": { 871 | "type": "object", 872 | "properties": { 873 | "id": { 874 | "type": "integer", 875 | "format": "int64", 876 | "example": 10 877 | }, 878 | "petId": { 879 | "type": "integer", 880 | "format": "int64", 881 | "example": 198772 882 | }, 883 | "quantity": { 884 | "type": "integer", 885 | "format": "int32", 886 | "example": 7 887 | }, 888 | "shipDate": { 889 | "type": "string", 890 | "format": "date-time" 891 | }, 892 | "status": { 893 | "type": "string", 894 | "description": "Order Status", 895 | "example": "approved", 896 | "enum": ["placed", "approved", "delivered"] 897 | }, 898 | "complete": { 899 | "type": "boolean" 900 | } 901 | }, 902 | "xml": { 903 | "name": "order" 904 | } 905 | }, 906 | "Customer": { 907 | "type": "object", 908 | "properties": { 909 | "id": { 910 | "type": "integer", 911 | "format": "int64", 912 | "example": 100000 913 | }, 914 | "username": { 915 | "type": "string", 916 | "example": "fehguy" 917 | }, 918 | "address": { 919 | "type": "array", 920 | "xml": { 921 | "name": "addresses", 922 | "wrapped": true 923 | }, 924 | "items": { 925 | "$ref": "#/components/schemas/Address" 926 | } 927 | } 928 | }, 929 | "xml": { 930 | "name": "customer" 931 | } 932 | }, 933 | "Address": { 934 | "type": "object", 935 | "properties": { 936 | "street": { 937 | "type": "string", 938 | "example": "437 Lytton" 939 | }, 940 | "city": { 941 | "type": "string", 942 | "example": "Palo Alto" 943 | }, 944 | "state": { 945 | "type": "string", 946 | "example": "CA" 947 | }, 948 | "zip": { 949 | "type": "string", 950 | "example": "94301" 951 | } 952 | }, 953 | "xml": { 954 | "name": "address" 955 | } 956 | }, 957 | "Category": { 958 | "type": "object", 959 | "properties": { 960 | "id": { 961 | "type": "integer", 962 | "format": "int64", 963 | "example": 1 964 | }, 965 | "name": { 966 | "type": "string", 967 | "example": "Dogs" 968 | } 969 | }, 970 | "xml": { 971 | "name": "category" 972 | } 973 | }, 974 | "User": { 975 | "type": "object", 976 | "properties": { 977 | "id": { 978 | "type": "integer", 979 | "format": "int64", 980 | "example": 10 981 | }, 982 | "username": { 983 | "type": "string", 984 | "example": "theUser" 985 | }, 986 | "firstName": { 987 | "type": "string", 988 | "example": "John" 989 | }, 990 | "lastName": { 991 | "type": "string", 992 | "example": "James" 993 | }, 994 | "email": { 995 | "type": "string", 996 | "example": "john@email.com" 997 | }, 998 | "password": { 999 | "type": "string", 1000 | "example": "12345" 1001 | }, 1002 | "phone": { 1003 | "type": "string", 1004 | "example": "12345" 1005 | }, 1006 | "userStatus": { 1007 | "type": "integer", 1008 | "description": "User Status", 1009 | "format": "int32", 1010 | "example": 1 1011 | } 1012 | }, 1013 | "xml": { 1014 | "name": "user" 1015 | } 1016 | }, 1017 | "Tag": { 1018 | "type": "object", 1019 | "properties": { 1020 | "id": { 1021 | "type": "integer", 1022 | "format": "int64" 1023 | }, 1024 | "name": { 1025 | "type": "string" 1026 | } 1027 | }, 1028 | "xml": { 1029 | "name": "tag" 1030 | } 1031 | }, 1032 | "Pet": { 1033 | "required": ["name", "photoUrls"], 1034 | "type": "object", 1035 | "properties": { 1036 | "id": { 1037 | "type": "integer", 1038 | "format": "int64", 1039 | "example": 10 1040 | }, 1041 | "name": { 1042 | "type": "string", 1043 | "example": "doggie" 1044 | }, 1045 | "category": { 1046 | "$ref": "#/components/schemas/Category" 1047 | }, 1048 | "photoUrls": { 1049 | "type": "array", 1050 | "xml": { 1051 | "wrapped": true 1052 | }, 1053 | "items": { 1054 | "type": "string", 1055 | "xml": { 1056 | "name": "photoUrl" 1057 | } 1058 | } 1059 | }, 1060 | "tags": { 1061 | "type": "array", 1062 | "xml": { 1063 | "wrapped": true 1064 | }, 1065 | "items": { 1066 | "$ref": "#/components/schemas/Tag" 1067 | } 1068 | }, 1069 | "status": { 1070 | "type": "string", 1071 | "description": "pet status in the store", 1072 | "enum": ["available", "pending", "sold"] 1073 | } 1074 | }, 1075 | "xml": { 1076 | "name": "pet" 1077 | } 1078 | }, 1079 | "ApiResponse": { 1080 | "type": "object", 1081 | "properties": { 1082 | "code": { 1083 | "type": "integer", 1084 | "format": "int32" 1085 | }, 1086 | "type": { 1087 | "type": "string" 1088 | }, 1089 | "message": { 1090 | "type": "string" 1091 | } 1092 | }, 1093 | "xml": { 1094 | "name": "##default" 1095 | } 1096 | } 1097 | }, 1098 | "requestBodies": { 1099 | "Pet": { 1100 | "description": "Pet object that needs to be added to the store", 1101 | "content": { 1102 | "application/json": { 1103 | "schema": { 1104 | "$ref": "#/components/schemas/Pet" 1105 | } 1106 | }, 1107 | "application/xml": { 1108 | "schema": { 1109 | "$ref": "#/components/schemas/Pet" 1110 | } 1111 | } 1112 | } 1113 | }, 1114 | "UserArray": { 1115 | "description": "List of user object", 1116 | "content": { 1117 | "application/json": { 1118 | "schema": { 1119 | "type": "array", 1120 | "items": { 1121 | "$ref": "#/components/schemas/User" 1122 | } 1123 | } 1124 | } 1125 | } 1126 | } 1127 | }, 1128 | "securitySchemes": { 1129 | "petstore_auth": { 1130 | "type": "oauth2", 1131 | "flows": { 1132 | "implicit": { 1133 | "authorizationUrl": "https://petstore3.swagger.io/oauth/authorize", 1134 | "scopes": { 1135 | "write:pets": "modify pets in your account", 1136 | "read:pets": "read your pets" 1137 | } 1138 | } 1139 | } 1140 | }, 1141 | "api_key": { 1142 | "type": "apiKey", 1143 | "name": "api_key", 1144 | "in": "header" 1145 | } 1146 | } 1147 | } 1148 | } 1149 | --------------------------------------------------------------------------------