├── .formatter.exs ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── examples ├── README.md └── user.ex ├── lib ├── ecto_api.ex └── ecto_api │ ├── builder.ex │ ├── builders │ └── basic.ex │ ├── client.ex │ ├── clients │ └── http.ex │ ├── helpers.ex │ ├── resolver.ex │ └── resolvers │ └── rest.ex ├── mix.exs ├── mix.lock └── test ├── ecto_api_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | ecto_api-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Flávio Moreira Vieira 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # EctoApi 2 | 3 | This is one idea that I've been experiment with and I'm making it public so there can be some discussion to improve these ideas. 4 | Although the name initially might drive someone to think that it's only for http api, the way i'm envisioning this is that it can be used with any sort of communication protocol. 5 | Even asynchronous protocols might fit here. 6 | 7 | ## Proposed API 8 | 9 | I'm thinking t mimic `Ecto.Repo` for `EctoApi` with some restrictions and additions: 10 | 11 | - `insert_all` and `update_all` aren't planned to be available. 12 | - `all` might happen, but not soon. I'm still uncertain if it's better to support a limitted version of `Ecto.Query` or build a proper DSL for this. 13 | - `get_by` from `Ecto.Repo`usually just return a single entry so for the current lack of `all` I'm adding `get_all`. 14 | - I'm currently working on how `preload` should work. 15 | - so the available functions are `insert`, `update`, `delete`, `get`, `get_by`, `get_all`. 16 | 17 | ## Behaviours and internal nomenclature 18 | 19 | - `EctoApi.Client`: this is where the side effects happen. All the communication are wrapped around this. 20 | - `EctoApi.Resolver`: receives the metadata from the resource and received params and build all the information that `EctoApi.Client` needs to do the external communication. 21 | - `EctoApi.Builder`: takes the response from the `EctoApi.Client` and cast the resource from it. It also dumps the resource to a proper structure that `EctoApi.Resolver` can use to build requests with it. 22 | 23 | Although `EctoApi.Client` and `EctoApi.Resolver` hints to http and crud operations, this is not supposed to restrict implementations of those behaviors. 24 | A GrapQL that only requires a `get` and `post` methods, might use the `put` and `delete` functions just to build context for the way to build the request. 25 | 26 | ## Current explorations and doubts 27 | 28 | ### Configuration and sensible defaults 29 | 30 | I'm not a fan of library wide configurations. 31 | I've been thinking to do something like `Ecto.Repo`, in a way that you implement `EctoApi` for your application and you set it for a single implementation. 32 | Other possibility here is that the resource itself describes what should be used to resolve it, so a `User` would have `client/0`, `resolver/0` and `builder/0` functions returning the proper ones to be used with that specific resource. 33 | The former might lead to difficuties when using resources that requires different resolvers, clients and builders. 34 | The later might require a lot of boiler plate in place for a resource to be used. 35 | 36 | ### Preload and relations 37 | 38 | There are some issues with relations and preloading them. 39 | 40 | The first one that comes to mind is that some relations might just not work back and forth. 41 | What I mean with it is that a `belongs_to` might not translate properly to a `has_one` or `has_many` relation. 42 | 43 | Other issue that I see is about pagination. 44 | For http apis this would make impossible to preload all the related resources with a single request. 45 | Should it go through all pages with a single preload or it's better to set pagination options when preloading and allow to preload more data with other later requests. 46 | This not only relates to the lazyness of the operation but on the proper way to configure the relation and set the default pagination. 47 | 48 | ## Additions and possible nice features to have in the future 49 | 50 | I'm thinking to add some caching and pooling strategies but this only can come up once the configuration and preload explorations are done. 51 | Besides that I'm thinking to have a default Clients/Resolvers for basic restful apis, jsonapi specification and graphql. 52 | I didn't explore grpc yet, but it might make sense to have one for it too. 53 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | Some examples to test and explore the proposed api for the library. 4 | 5 | ## reqres.in user 6 | 7 | ### insert 8 | 9 | ```elixir 10 | %User{} 11 | |> User.changeset(%{first_name: "John", last_name: "Doe"}) 12 | |> EctoApi.insert(resolver: EctoApi.Resolvers.Rest, client: EctoApi.Clients.Http, builder: User) 13 | ``` 14 | 15 | ### update 16 | 17 | ```elixir 18 | {:ok, user} = 19 | %User{} 20 | |> User.changeset(%{first_name: "John", last_name: "Doe"}) 21 | |> EctoApi.insert(resolver: EctoApi.Resolvers.Rest, client: EctoApi.Clients.Http, builder: User) 22 | 23 | user 24 | |> User.changeset(%{first_name: "Janet", last_name: "Doe"}) 25 | |> EctoApi.update(resolver: EctoApi.Resolvers.Rest, client: EctoApi.Clients.Http, builder: User) 26 | ``` 27 | 28 | ### delete 29 | 30 | ```elixir 31 | {:ok, user} = 32 | %User{} 33 | |> User.changeset(%{first_name: "John", last_name: "Doe"}) 34 | |> EctoApi.insert(resolver: EctoApi.Resolvers.Rest, client: EctoApi.Clients.Http, builder: User) 35 | 36 | user 37 | |> EctoApi.delete(resolver: EctoApi.Resolvers.Rest, client: EctoApi.Clients.Http, builder: User) 38 | ``` 39 | 40 | ### get 41 | 42 | ```elixir 43 | User 44 | |> EctoApi.get(6, resolver: EctoApi.Resolvers.Rest, client: EctoApi.Clients.Http, builder: User) 45 | ``` 46 | 47 | ### get_by 48 | 49 | ```elixir 50 | User 51 | |> EctoApi.get_by([{"page", "2"}], resolver: EctoApi.Resolvers.Rest, client: EctoApi.Clients.Http, builder: User) 52 | ``` 53 | 54 | ### get_all 55 | 56 | ```elixir 57 | User 58 | |> EctoApi.get_by([{"page", "2"}], resolver: EctoApi.Resolvers.Rest, client: EctoApi.Clients.Http, builder: User) 59 | ``` 60 | -------------------------------------------------------------------------------- /examples/user.ex: -------------------------------------------------------------------------------- 1 | defmodule User do 2 | use Ecto.Schema 3 | 4 | @schema_prefix "http://reqres.in/api" 5 | schema "/users" do 6 | field(:email, :string) 7 | field(:first_name, :string) 8 | field(:last_name, :string) 9 | field(:avatar, :string) 10 | end 11 | 12 | def changeset(struct, params) do 13 | fields = ~w(id email first_name last_name avatar)a 14 | 15 | Ecto.Changeset.cast(struct, params, fields) 16 | end 17 | 18 | def cast(_queryable, response, _opts) do 19 | with status when status in [200, 201, 204] <- response.status, 20 | params <- extract(response.body), 21 | struct <- cast(params) do 22 | {:ok, struct} 23 | else 24 | _ -> {:error, :casting} 25 | end 26 | end 27 | 28 | def cast_many(_queryable, response, _opts) do 29 | users = 30 | response.body["data"] 31 | |> Enum.map(&cast/1) 32 | 33 | {:ok, users} 34 | end 35 | 36 | def dump(%Ecto.Changeset{} = changeset) do 37 | params = changeset.changes 38 | id = changeset.data.id 39 | {:ok, %{params: params, id: id}} 40 | end 41 | 42 | def dump(%__MODULE__{} = struct) do 43 | params = 44 | struct 45 | |> Map.take(__MODULE__.__schema__(:fields)) 46 | 47 | {:ok, %{params: params, id: struct.id}} 48 | end 49 | 50 | def dump(_), do: {:error, :dump} 51 | 52 | defp extract(%{"data" => nil}), do: nil 53 | defp extract(%{"data" => [user | _]}), do: user 54 | defp extract(%{"data" => user}), do: user 55 | defp extract(user) when is_map(user), do: user 56 | defp extract(_), do: nil 57 | 58 | defp cast(nil), do: cast(%{}) 59 | 60 | defp cast(params) do 61 | %__MODULE__{} 62 | |> changeset(params) 63 | |> Ecto.Changeset.apply_changes() 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/ecto_api.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoApi do 2 | @moduledoc """ 3 | """ 4 | alias EctoApi.Helpers 5 | 6 | def insert(struct_or_changeset, opts) do 7 | {resolver, resolver_opts} = Helpers.resolver(opts) 8 | {client, client_opts} = Helpers.client(opts) 9 | {builder, builder_opts} = Helpers.builder(opts) 10 | queryable = Helpers.queryable(struct_or_changeset) 11 | metadata = Helpers.metadata(queryable) 12 | 13 | with {:ok, params} <- builder.dump(struct_or_changeset), 14 | {:ok, resolved} <- resolver.create(metadata, params, resolver_opts), 15 | {:ok, response} <- client.post(resolved, client_opts), 16 | {:ok, schema} <- builder.cast(queryable, response, builder_opts) do 17 | {:ok, schema} 18 | end 19 | end 20 | 21 | def update(struct_or_changeset, opts) do 22 | {resolver, resolver_opts} = Helpers.resolver(opts) 23 | {client, client_opts} = Helpers.client(opts) 24 | {builder, builder_opts} = Helpers.builder(opts) 25 | queryable = Helpers.queryable(struct_or_changeset) 26 | metadata = Helpers.metadata(queryable) 27 | 28 | with {:ok, params} <- builder.dump(struct_or_changeset), 29 | {:ok, resolved} <- resolver.update(metadata, params, resolver_opts), 30 | {:ok, response} <- client.put(resolved, client_opts), 31 | {:ok, schema} <- builder.cast(queryable, response, builder_opts) do 32 | {:ok, schema} 33 | end 34 | end 35 | 36 | def delete(struct_or_changeset, opts) do 37 | {resolver, resolver_opts} = Helpers.resolver(opts) 38 | {client, client_opts} = Helpers.client(opts) 39 | {builder, builder_opts} = Helpers.builder(opts) 40 | queryable = Helpers.queryable(struct_or_changeset) 41 | metadata = Helpers.metadata(queryable) 42 | 43 | with {:ok, params} <- builder.dump(struct_or_changeset), 44 | {:ok, resolved} <- resolver.delete(metadata, params, resolver_opts), 45 | {:ok, response} <- client.delete(resolved, client_opts), 46 | {:ok, schema} <- builder.cast(queryable, response, builder_opts) do 47 | {:ok, schema} 48 | end 49 | end 50 | 51 | def get(queryable, id, opts) do 52 | {resolver, resolver_opts} = Helpers.resolver(opts) 53 | {client, client_opts} = Helpers.client(opts) 54 | {builder, builder_opts} = Helpers.builder(opts) 55 | metadata = Helpers.metadata(queryable) 56 | 57 | with {:ok, resolved} <- resolver.read(metadata, id, resolver_opts), 58 | {:ok, response} <- client.get(resolved, client_opts), 59 | {:ok, schema} <- builder.cast(queryable, response, builder_opts) do 60 | schema 61 | else 62 | _ -> nil 63 | end 64 | end 65 | 66 | def get_by(queryable, clauses, opts) do 67 | {resolver, resolver_opts} = Helpers.resolver(opts) 68 | {client, client_opts} = Helpers.client(opts) 69 | {builder, builder_opts} = Helpers.builder(opts) 70 | metadata = Helpers.metadata(queryable) 71 | 72 | with {:ok, resolved} <- resolver.list(metadata, clauses, resolver_opts), 73 | {:ok, response} <- client.get(resolved, client_opts), 74 | {:ok, schema} <- builder.cast(queryable, response, builder_opts) do 75 | schema 76 | else 77 | _ -> nil 78 | end 79 | end 80 | 81 | def get_all(queryable, clauses, opts) do 82 | {resolver, resolver_opts} = Helpers.resolver(opts) 83 | {client, client_opts} = Helpers.client(opts) 84 | {builder, builder_opts} = Helpers.builder(opts) 85 | metadata = Helpers.metadata(queryable) 86 | 87 | with {:ok, resolved} <- resolver.list(metadata, clauses, resolver_opts), 88 | {:ok, response} <- client.get(resolved, client_opts), 89 | {:ok, schema} <- builder.cast_many(queryable, response, builder_opts) do 90 | schema 91 | else 92 | _ -> nil 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/ecto_api/builder.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoApi.Builder do 2 | @callback cast(Ecto.Queryable.t(), any(), Keyword.t()) :: 3 | {:ok, Ecto.Schema.t()} | {:error, any()} 4 | @callback cast_many(Ecto.Queryable.t(), any(), Keyword.t()) :: 5 | {:ok, [Ecto.Schema.t()]} | {:error, any()} 6 | end 7 | -------------------------------------------------------------------------------- /lib/ecto_api/builders/basic.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoApi.Builders.Basic do 2 | @behaviour EctoApi.Builder 3 | 4 | def cast(queryable, response, opts) do 5 | struct = queryable.__struct__() 6 | fields = queryable.__schema__(:fields) 7 | 8 | struct = 9 | struct 10 | |> Ecto.Changeset.cast(response.body, fields, opts) 11 | |> Ecto.Changeset.apply_changes() 12 | 13 | {:ok, struct} 14 | end 15 | 16 | def cast_many(queryable, response, opts) do 17 | structs = 18 | response 19 | |> Enum.map(&cast(queryable, &1, opts)) 20 | |> Enum.map(&unwrap/1) 21 | 22 | {:ok, structs} 23 | end 24 | 25 | defp unwrap({:ok, struct}), do: struct 26 | end 27 | -------------------------------------------------------------------------------- /lib/ecto_api/client.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoApi.Client do 2 | @callback get(any(), Keyword.t()) :: {:ok, any()} | {:error, any()} 3 | end 4 | -------------------------------------------------------------------------------- /lib/ecto_api/clients/http.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoApi.Clients.Http do 2 | @behaviour EctoApi.Client 3 | 4 | def post(request, opts) do 5 | request[:url] 6 | |> client(opts) 7 | |> Tesla.post(request[:path], request[:body], 8 | query: request[:query], 9 | headers: request[:headers] 10 | ) 11 | end 12 | 13 | def put(request, opts) do 14 | request[:url] 15 | |> client(opts) 16 | |> Tesla.put(request[:path], request[:body], 17 | query: request[:query], 18 | headers: request[:headers] 19 | ) 20 | end 21 | 22 | def delete(request, opts) do 23 | request[:url] 24 | |> client(opts) 25 | |> Tesla.delete(request[:path], 26 | query: request[:query], 27 | headers: request[:headers] 28 | ) 29 | end 30 | 31 | def get(request, opts) do 32 | request[:url] 33 | |> client(opts) 34 | |> Tesla.get(request[:path], query: request[:query], headers: request[:headers]) 35 | end 36 | 37 | defp client(url, opts) do 38 | middleware = build_middleware(url, opts[:json], opts[:auth]) 39 | adapter = opts[:adapter] || Tesla.Adapter.Mint 40 | 41 | Tesla.client(middleware, adapter) 42 | end 43 | 44 | defp build_middleware(url, json, auth) do 45 | [{Tesla.Middleware.BaseUrl, url}, Tesla.Middleware.FollowRedirects] 46 | |> put_json(json) 47 | |> put_auth(auth) 48 | |> Enum.reverse() 49 | end 50 | 51 | defp put_json(middlewares, nil), do: [Tesla.Middleware.JSON | middlewares] 52 | defp put_json(middlewares, opts), do: [{Tesla.Middleware.JSON, opts} | middlewares] 53 | 54 | defp put_auth(middlewares, nil), do: middlewares 55 | defp put_auth(middlewares, opts), do: [{Tesla.Middleware.Headers, opts} | middlewares] 56 | end 57 | -------------------------------------------------------------------------------- /lib/ecto_api/helpers.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoApi.Helpers do 2 | def resolver(opts) do 3 | case opts[:resolver] do 4 | resolver when is_atom(resolver) -> {resolver, []} 5 | {resolver, resolver_opts} -> {resolver, resolver_opts} 6 | end 7 | end 8 | 9 | def client(opts) do 10 | case opts[:client] do 11 | client when is_atom(client) -> {client, []} 12 | {client, client_opts} -> {client, client_opts} 13 | end 14 | end 15 | 16 | def builder(opts) do 17 | case opts[:builder] do 18 | builder when is_atom(builder) -> {builder, []} 19 | {builder, builder_opts} -> {builder, builder_opts} 20 | end 21 | end 22 | 23 | def metadata(queryable) do 24 | source = queryable.__schema__(:source) 25 | prefix = queryable.__schema__(:prefix) 26 | 27 | %{source: source, prefix: prefix, queryable: queryable} 28 | end 29 | 30 | def queryable(%Ecto.Changeset{data: %{__struct__: queryable}}), do: queryable 31 | def queryable(%{__struct__: queryable}), do: queryable 32 | end 33 | -------------------------------------------------------------------------------- /lib/ecto_api/resolver.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoApi.Resolver do 2 | @callback read(map(), any(), Keyword.t()) :: {:ok, any()} | {:error, any()} 3 | @callback list(map(), Keyword.t(), Keyword.t()) :: {:ok, any()} | {:error, any()} 4 | end 5 | -------------------------------------------------------------------------------- /lib/ecto_api/resolvers/rest.ex: -------------------------------------------------------------------------------- 1 | defmodule EctoApi.Resolvers.Rest do 2 | @behaviour EctoApi.Resolver 3 | 4 | def create(metadata, %{params: params}, opts) do 5 | url = build_url(metadata, opts) 6 | headers = build_headers(opts) 7 | 8 | {:ok, %{url: url, path: "", body: params, query: [], headers: headers}} 9 | end 10 | 11 | def update(metadata, %{params: params, id: id}, opts) do 12 | url = build_url(metadata, opts) 13 | path = build_path(id, opts) 14 | headers = build_headers(opts) 15 | 16 | {:ok, %{url: url, path: path, body: params, query: [], headers: headers}} 17 | end 18 | 19 | def delete(metadata, %{id: id}, opts) do 20 | url = build_url(metadata, opts) 21 | path = build_path(id, opts) 22 | headers = build_headers(opts) 23 | 24 | {:ok, %{url: url, path: path, query: [], headers: headers}} 25 | end 26 | 27 | def read(metadata, id, opts) do 28 | url = build_url(metadata, opts) 29 | path = build_path(id, opts) 30 | headers = build_headers(opts) 31 | 32 | {:ok, %{url: url, path: path, query: [], headers: headers}} 33 | end 34 | 35 | def list(metadata, clauses, opts) do 36 | url = build_url(metadata, opts) 37 | query = build_query(clauses, opts) 38 | headers = build_headers(opts) 39 | 40 | {:ok, %{url: url, path: "", query: query, headers: headers}} 41 | end 42 | 43 | defp build_url(metadata, opts) do 44 | (opts[:base_url] || "") <> metadata[:prefix] <> metadata[:source] 45 | end 46 | 47 | defp build_path(id, _opts) do 48 | "/" <> to_string(id) 49 | end 50 | 51 | defp build_headers(opts) do 52 | opts[:headers] 53 | |> List.wrap() 54 | |> Enum.filter(&possible_header/1) 55 | end 56 | 57 | defp possible_header({k, v}) when is_binary(k) and is_binary(v), do: true 58 | defp possible_header(_), do: false 59 | 60 | defp build_query(clauses, _opts) do 61 | clauses 62 | |> List.wrap() 63 | |> Enum.filter(&possible_query/1) 64 | end 65 | 66 | defp possible_query({k, v}) when is_binary(k) and is_binary(v), do: true 67 | defp possible_query({k, v}) when is_atom(k) and is_binary(v), do: true 68 | defp possible_query(_), do: false 69 | end 70 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoApi.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :ecto_api, 7 | name: "EctoApi", 8 | source_url: url(), 9 | description: description(), 10 | package: package(), 11 | version: "0.0.1", 12 | elixir: "~> 1.12", 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps() 15 | ] 16 | end 17 | 18 | def application do 19 | [ 20 | extra_applications: [:logger] 21 | ] 22 | end 23 | 24 | defp deps do 25 | [ 26 | {:ecto, "~> 3.6"}, 27 | {:tesla, "~> 1.4", optional: true}, 28 | {:mint, "~> 1.3", optional: true}, 29 | {:jason, "~> 1.2", optional: true}, 30 | {:castore, "~> 0.1.10", optional: true}, 31 | {:ex_doc, "~> 0.14", only: :dev, runtime: false} 32 | ] 33 | end 34 | 35 | defp description, do: "NOT STABLE YET. A library to describe resources from external apis." 36 | 37 | defp package do 38 | [ 39 | files: ~w(lib .formatter.exs mix.exs mix.lock README.md LICENSE CHANGELOG.md), 40 | licenses: ["Apache-2.0"], 41 | links: %{"Github" => url()} 42 | ] 43 | end 44 | 45 | defp url, do: "https://github.com/fcevado/ecto_api" 46 | end 47 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "0.1.10", "b01a007416a0ae4188e70b3b306236021b16c11474038ead7aff79dd75538c23", [:mix], [], "hexpm", "a48314e0cb45682db2ea27b8ebfa11bd6fa0a6e21a65e5772ad83ca136ff2665"}, 3 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 4 | "earmark_parser": {:hex, :earmark_parser, "1.4.13", "0c98163e7d04a15feb62000e1a891489feb29f3d10cb57d4f845c405852bbef8", [:mix], [], "hexpm", "d602c26af3a0af43d2f2645613f65841657ad6efc9f0e361c3b6c06b578214ba"}, 5 | "ecto": {:hex, :ecto, "3.6.2", "efdf52acfc4ce29249bab5417415bd50abd62db7b0603b8bab0d7b996548c2bc", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "efad6dfb04e6f986b8a3047822b0f826d9affe8e4ebdd2aeedbfcb14fd48884e"}, 6 | "ex_doc": {:hex, :ex_doc, "0.24.2", "e4c26603830c1a2286dae45f4412a4d1980e1e89dc779fcd0181ed1d5a05c8d9", [:mix], [{:earmark_parser, "~> 1.4.0", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "e134e1d9e821b8d9e4244687fb2ace58d479b67b282de5158333b0d57c6fb7da"}, 7 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 8 | "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, 9 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.1", "b5888c880d17d1cc3e598f05cdb5b5a91b7b17ac4eaf5f297cb697663a1094dd", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "db68c173234b07ab2a07f645a5acdc117b9f99d69ebf521821d89690ae6c6ec8"}, 10 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 11 | "mime": {:hex, :mime, "1.6.0", "dabde576a497cef4bbdd60aceee8160e02a6c89250d6c0b29e56c0dfb00db3d2", [:mix], [], "hexpm", "31a1a8613f8321143dde1dafc36006a17d28d02bdfecb9e95a880fa7aabd19a7"}, 12 | "mint": {:hex, :mint, "1.3.0", "396b3301102f7b775e103da5a20494b25753aed818d6d6f0ad222a3a018c3600", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "a9aac960562e43ca69a77e5176576abfa78b8398cec5543dd4fb4ab0131d5c1e"}, 13 | "nimble_parsec": {:hex, :nimble_parsec, "1.1.0", "3a6fca1550363552e54c216debb6a9e95bd8d32348938e13de5eda962c0d7f89", [:mix], [], "hexpm", "08eb32d66b706e913ff748f11694b17981c0b04a33ef470e33e11b3d3ac8f54b"}, 14 | "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, 15 | "tesla": {:hex, :tesla, "1.4.1", "ff855f1cac121e0d16281b49e8f066c4a0d89965f98864515713878cca849ac8", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:exjsx, ">= 3.0.0", [hex: :exjsx, repo: "hexpm", optional: true]}, {:finch, "~> 0.3", [hex: :finch, repo: "hexpm", optional: true]}, {:fuse, "~> 2.4", [hex: :fuse, repo: "hexpm", optional: true]}, {:gun, "~> 1.3", [hex: :gun, repo: "hexpm", optional: true]}, {:hackney, "~> 1.6", [hex: :hackney, repo: "hexpm", optional: true]}, {:ibrowse, "~> 4.4.0", [hex: :ibrowse, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:poison, ">= 1.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "95f5de35922c8c4b3945bee7406f66eb680b0955232f78f5fb7e853aa1ce201a"}, 16 | } 17 | -------------------------------------------------------------------------------- /test/ecto_api_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EctoApiTest do 2 | use ExUnit.Case 3 | doctest EctoApi 4 | 5 | test "greets the world" do 6 | assert EctoApi.hello() == :world 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------