├── .latest-tag-stripe-openapi-sdk ├── test ├── test_helper.exs ├── stripe_test.exs ├── open_api_gen_test.exs ├── support │ └── case.ex ├── stripe │ ├── openapi │ │ └── path_test.exs │ ├── issuing │ │ └── card_test.exs │ ├── subscription_test.exs │ ├── invoice_test.exs │ └── customer_test.exs └── integration │ ├── client_test.exs │ ├── backwards_compatible_test.exs │ ├── telemetry_test.exs │ └── retries_test.exs ├── .github ├── blocks │ └── all.json └── workflows │ ├── test.yml │ └── codegen.yml ├── .formatter.exs ├── lib ├── stripe │ ├── open_api │ │ ├── blueprint │ │ │ ├── any_of.ex │ │ │ ├── list_of.ex │ │ │ ├── module.ex │ │ │ ├── reference.ex │ │ │ ├── parameter.ex │ │ │ ├── search_result.ex │ │ │ ├── parameter │ │ │ │ └── schema.ex │ │ │ ├── schema.ex │ │ │ └── operation.ex │ │ ├── phases │ │ │ ├── version.ex │ │ │ ├── parse.ex │ │ │ ├── build_documentation.ex │ │ │ ├── build_modules.ex │ │ │ ├── build_operations.ex │ │ │ └── compile.ex │ │ ├── blueprint.ex │ │ ├── path.ex │ │ └── search_result.ex │ ├── http_client.ex │ ├── list.ex │ ├── telemetry.ex │ ├── http_client │ │ └── httpc.ex │ └── open_api.ex └── stripe.ex ├── .gitignore ├── LICENSE ├── README.md ├── mix.lock └── mix.exs /.latest-tag-stripe-openapi-sdk: -------------------------------------------------------------------------------- 1 | v806 2 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Mox.defmock(TestClient, for: Stripe.HTTPClient) 2 | ExUnit.start() 3 | -------------------------------------------------------------------------------- /test/stripe_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StripeTest do 2 | use ExUnit.Case 3 | # doctest OpenApiGen 4 | end 5 | -------------------------------------------------------------------------------- /.github/blocks/all.json: -------------------------------------------------------------------------------- 1 | { 2 | "": { 3 | "default": "githubnext__blocks-examples__overview" 4 | } 5 | } -------------------------------------------------------------------------------- /test/open_api_gen_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Stripe.OpenApiTest do 2 | use ExUnit.Case 3 | # doctest OpenApiGen 4 | end 5 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /lib/stripe/open_api/blueprint/any_of.ex: -------------------------------------------------------------------------------- 1 | defmodule OpenApiGen.Blueprint.AnyOf do 2 | @moduledoc false 3 | defstruct any_of: [] 4 | end 5 | -------------------------------------------------------------------------------- /lib/stripe/open_api/blueprint/list_of.ex: -------------------------------------------------------------------------------- 1 | defmodule OpenApiGen.Blueprint.ListOf do 2 | @moduledoc false 3 | defstruct [ 4 | :type_of 5 | ] 6 | end 7 | -------------------------------------------------------------------------------- /lib/stripe/open_api/blueprint/module.ex: -------------------------------------------------------------------------------- 1 | defmodule OpenApiGen.Blueprint.Module do 2 | @moduledoc false 3 | defstruct [:name, :prefix, :description] 4 | end 5 | -------------------------------------------------------------------------------- /lib/stripe/open_api/blueprint/reference.ex: -------------------------------------------------------------------------------- 1 | defmodule OpenApiGen.Blueprint.Reference do 2 | @moduledoc false 3 | defstruct [ 4 | :name 5 | ] 6 | end 7 | -------------------------------------------------------------------------------- /lib/stripe/open_api/blueprint/parameter.ex: -------------------------------------------------------------------------------- 1 | defmodule OpenApiGen.Blueprint.Parameter do 2 | @moduledoc false 3 | defstruct [:in, :name, :required, :schema] 4 | end 5 | -------------------------------------------------------------------------------- /lib/stripe/open_api/blueprint/search_result.ex: -------------------------------------------------------------------------------- 1 | defmodule OpenApiGen.Blueprint.SearchResult do 2 | @moduledoc false 3 | defstruct [ 4 | :type_of 5 | ] 6 | end 7 | -------------------------------------------------------------------------------- /lib/stripe/open_api/blueprint/parameter/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule OpenApiGen.Blueprint.Parameter.Schema do 2 | @moduledoc false 3 | defstruct [:name, :title, :type, items: [], properties: [], any_of: []] 4 | end 5 | -------------------------------------------------------------------------------- /lib/stripe/open_api/blueprint/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule OpenApiGen.Blueprint.Schema do 2 | @moduledoc false 3 | defstruct [:name, :description, :module, operations: [], properties: [], expandable_fields: []] 4 | end 5 | -------------------------------------------------------------------------------- /lib/stripe/open_api/phases/version.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.OpenApi.Phases.Version do 2 | @moduledoc false 3 | def run(blueprint, _options) do 4 | {:ok, %{blueprint | api_version: blueprint.source["info"]["version"]}} 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/stripe/open_api/phases/parse.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.OpenApi.Phases.Parse do 2 | @moduledoc false 3 | def run(blueprint, options \\ []) do 4 | contents = File.read!(Keyword.fetch!(options, :path)) 5 | parsed_contents = Jason.decode!(contents) 6 | {:ok, %{blueprint | source: parsed_contents}} 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /test/support/case.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.Case do 2 | defmacro __using__(opts \\ [async: true]) do 3 | quote do 4 | use ExUnit.Case, unquote(opts) 5 | import Stripe.Case 6 | end 7 | end 8 | 9 | def sigil_f(string, []) do 10 | {_result, _binding} = Code.eval_string(string, [], __ENV__) 11 | string 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/stripe/open_api/blueprint.ex: -------------------------------------------------------------------------------- 1 | defmodule OpenApiGen.Blueprint do 2 | @moduledoc false 3 | defstruct [:components, :source, :operations, :modules, :api_version] 4 | 5 | def lookup_component(ref, blueprint) do 6 | blueprint.components 7 | |> Enum.find(fn {_, component} -> component.ref == ref end) 8 | |> elem(1) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/stripe/open_api/blueprint/operation.ex: -------------------------------------------------------------------------------- 1 | defmodule OpenApiGen.Blueprint.Operation do 2 | @moduledoc false 3 | defstruct [ 4 | :id, 5 | :method, 6 | :path, 7 | :name, 8 | :description, 9 | :parameters, 10 | :path_parameters, 11 | :query_parameters, 12 | :deprecated, 13 | :operation, 14 | :body_parameters, 15 | :success_response 16 | ] 17 | end 18 | -------------------------------------------------------------------------------- /lib/stripe/http_client.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.HTTPClient do 2 | @callback init() :: :ok 3 | 4 | @callback request( 5 | method :: atom(), 6 | url :: binary(), 7 | headers :: [{binary(), binary()}], 8 | body :: binary(), 9 | opts :: keyword() 10 | ) :: 11 | {:ok, 12 | %{ 13 | status: 200..599, 14 | headers: [{binary(), binary()}], 15 | body: binary() 16 | }} 17 | | {:error, term()} 18 | end 19 | -------------------------------------------------------------------------------- /lib/stripe/open_api/path.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.OpenApi.Path do 2 | @moduledoc false 3 | def replace_path_params(path, path_param_defs, path_params_values) do 4 | {path, []} = 5 | path_param_defs 6 | |> Enum.reduce({path, path_params_values}, fn path_param_def, 7 | {path, [path_param_value | values]} -> 8 | path_param_name = path_param_def.name 9 | 10 | path = String.replace(path, "{#{path_param_name}}", path_param_value) 11 | {path, values} 12 | end) 13 | 14 | path 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/stripe/openapi/path_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Stripe.OpenApi.PathTest do 2 | use Stripe.Case 3 | 4 | test "replaces url params" do 5 | path = "http://localhost:12111/v1/subscriptions/{subscription_exposed_id}" 6 | 7 | path_param_defs = [ 8 | %OpenApiGen.Blueprint.Parameter{ 9 | in: "path", 10 | name: "subscription_exposed_id", 11 | required: true, 12 | schema: %{"maxLength" => 5000, "type" => "string"} 13 | } 14 | ] 15 | 16 | path_params_values = ["sub123"] 17 | 18 | assert "http://localhost:12111/v1/subscriptions/sub123" = 19 | Stripe.OpenApi.Path.replace_path_params(path, path_param_defs, path_params_values) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /.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 | striped-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | .DS_Store -------------------------------------------------------------------------------- /lib/stripe/open_api/phases/build_documentation.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.OpenApi.Phases.BuildDocumentation do 2 | @moduledoc false 3 | def run(blueprint, _options \\ []) do 4 | operations = 5 | Enum.map(blueprint.operations, fn {key, operation} -> 6 | {key, build_description(operation)} 7 | end) 8 | |> Map.new() 9 | 10 | {:ok, %{blueprint | operations: operations}} 11 | end 12 | 13 | defp build_description(operation) do 14 | %{operation | description: do_build_description(operation)} 15 | end 16 | 17 | defp do_build_description(operation) do 18 | description = fix_tags(operation.description) 19 | 20 | """ 21 | #{description} 22 | 23 | #### Details 24 | 25 | * Method: `#{operation.method}` 26 | * Path: `#{operation.path}` 27 | """ 28 | end 29 | 30 | defp fix_tags(docs) do 31 | Regex.replace(~r{.<\/p>}m, docs, ".\n

") 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /test/integration/client_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Stripe.ClientTest do 2 | use Stripe.Case 3 | 4 | defmodule TestClient do 5 | def init() do 6 | :ok 7 | end 8 | 9 | def request(:get, _, _, _, opts) do 10 | send(self(), opts) 11 | 12 | {:ok, 13 | %{ 14 | status: 200, 15 | body: """ 16 | { 17 | "object":"subscription", 18 | "unknown_attr": null 19 | } 20 | """, 21 | headers: [] 22 | }} 23 | end 24 | end 25 | 26 | test "opts are passed through" do 27 | client = 28 | Stripe.new( 29 | api_key: "sk_test_123", 30 | http_client: TestClient 31 | ) 32 | 33 | assert {:ok, %Stripe.Subscription{}} = 34 | Stripe.Subscription.retrieve(client, "sub123", %{expand: [:customer]}, 35 | http_opts: [timeout: 3000] 36 | ) 37 | 38 | assert_receive timeout: 3000 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/stripe/open_api/search_result.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.SearchResult do 2 | @moduledoc """ 3 | Some top-level API resource have support for retrieval via "search" API methods. For example, you can search charges, search customers, and search subscriptions. 4 | 5 | Stripe's search API methods utilize cursor-based pagination via the `page` request parameter and `next_page` response parameter. For example, if you make a search request and receive "next_page": "pagination_key" in the response, your subsequent call can include page=pagination_key to fetch the next page of results. 6 | 7 | See https://stripe.com/docs/search for more information. 8 | """ 9 | 10 | @type value :: term 11 | 12 | @type t(value) :: %__MODULE__{ 13 | object: binary, 14 | data: [value], 15 | has_more: boolean, 16 | total_count: integer | nil, 17 | next_page: binary | nil, 18 | url: binary 19 | } 20 | 21 | defstruct [:object, :data, :has_more, :total_count, :next_page, :url] 22 | end 23 | -------------------------------------------------------------------------------- /lib/stripe/list.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.List do 2 | @moduledoc """ 3 | All top-level API resources have support for bulk fetches via "list" API methods. For instance, you can list charges, list customers, and list invoices. These list API methods share a common structure, taking at least these three parameters: `limit`, `starting_after`, and `ending_before`. 4 | 5 | Stripe's list API methods utilize cursor-based pagination via the `starting_after` and `ending_before` parameters. Both parameters take an existing object ID value (see below) and return objects in reverse chronological order. The `ending_before` parameter returns objects listed before the named object. The `starting_after` parameter returns objects listed after the named object. These parameters are mutually exclusive -- only one of `starting_after` or `ending_before` may be used. 6 | """ 7 | 8 | @type value :: term 9 | 10 | @type t(value) :: %__MODULE__{ 11 | object: binary, 12 | data: [value], 13 | has_more: boolean, 14 | url: binary 15 | } 16 | 17 | defstruct [:object, :data, :has_more, :url] 18 | end 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Maarten van Vliet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, synchronize] 6 | push: 7 | branches: 8 | - 'main' 9 | 10 | jobs: 11 | test: 12 | env: 13 | MIX_ENV: test 14 | STRIPE_MOCK_VERSION: 0.144.0 15 | runs-on: ubuntu-latest 16 | name: Test (OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}) 17 | strategy: 18 | matrix: 19 | include: 20 | - elixir: 1.14.x 21 | otp: 25.x 22 | check_formatted: true 23 | services: 24 | stripe-mock: 25 | image: stripe/stripe-mock:v0.144.0 26 | ports: 27 | - 12111:12111 28 | - 12112:12112 29 | steps: 30 | - name: Clone code 31 | uses: actions/checkout@v2 32 | - name: Setup Elixir and Erlang 33 | uses: erlef/setup-beam@v1 34 | with: 35 | otp-version: ${{ matrix.otp }} 36 | elixir-version: ${{ matrix.elixir }} 37 | - name: Install Dependencies 38 | run: mix deps.get && mix deps.unlock --check-unused 39 | - name: Compile project 40 | run: mix compile --warnings-as-errors 41 | - name: Check formatting 42 | if: matrix.check_formatted 43 | run: mix format --check-formatted 44 | - name: Run tests 45 | run: mix test -------------------------------------------------------------------------------- /test/stripe/issuing/card_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Stripe.Issuing.CardTest do 2 | use Stripe.Case 3 | 4 | test "exports functions" do 5 | assert [ 6 | {:__struct__, 0}, 7 | {:__struct__, 1}, 8 | {:create, 1}, 9 | {:create, 2}, 10 | {:create, 3}, 11 | {:deliver_card, 2}, 12 | {:deliver_card, 3}, 13 | {:deliver_card, 4}, 14 | {:fail_card, 2}, 15 | {:fail_card, 3}, 16 | {:fail_card, 4}, 17 | {:list, 1}, 18 | {:list, 2}, 19 | {:list, 3}, 20 | {:retrieve, 2}, 21 | {:retrieve, 3}, 22 | {:retrieve, 4}, 23 | {:return_card, 2}, 24 | {:return_card, 3}, 25 | {:return_card, 4}, 26 | {:ship_card, 2}, 27 | {:ship_card, 3}, 28 | {:ship_card, 4}, 29 | {:update, 2}, 30 | {:update, 3}, 31 | {:update, 4} 32 | ] = Stripe.Issuing.Card.__info__(:functions) 33 | end 34 | 35 | @tag :stripe_mock 36 | test ~f{&Stripe.Issuing.Card.retrieve/2} do 37 | client = Stripe.new(api_key: "sk_test_123", base_url: "http://localhost:12111") 38 | assert {:ok, res} = Stripe.Issuing.Card.retrieve(client, "sub123") 39 | assert %Stripe.Issuing.Card{} = res 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/integration/backwards_compatible_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Stripe.BackwardsCompatibleTest do 2 | use Stripe.Case 3 | 4 | # https://stripe.com/docs/upgrades#what-changes-does-stripe-consider-to-be-backwards-compatible 5 | defmodule TestClientExtraAttr do 6 | def init() do 7 | :ok 8 | end 9 | 10 | def request(:get, _, _, _, _) do 11 | {:ok, 12 | %{ 13 | status: 200, 14 | body: """ 15 | { 16 | "object":"subscription", 17 | "unknown_attr": null 18 | } 19 | """, 20 | headers: [] 21 | }} 22 | end 23 | end 24 | 25 | test "handles extra attributes" do 26 | client = 27 | Stripe.new( 28 | api_key: "sk_test_123", 29 | http_client: TestClientExtraAttr 30 | ) 31 | 32 | assert {:ok, %Stripe.Subscription{}} = 33 | Stripe.Subscription.retrieve(client, "sub123", %{expand: [:customer]}) 34 | end 35 | 36 | defmodule TestClientNewObject do 37 | def init() do 38 | :ok 39 | end 40 | 41 | def request(:get, _, _, _, _) do 42 | {:ok, 43 | %{ 44 | status: 200, 45 | body: """ 46 | { 47 | "object":"unknown_object", 48 | "unknown_attr": null 49 | } 50 | """, 51 | headers: [] 52 | }} 53 | end 54 | end 55 | 56 | test "handles new objects" do 57 | client = 58 | Stripe.new( 59 | api_key: "sk_test_123", 60 | http_client: TestClientNewObject 61 | ) 62 | 63 | assert {:ok, %{object: "unknown_object", unknown_attr: nil}} = 64 | Stripe.Subscription.retrieve(client, "sub123", %{expand: [:customer]}) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/stripe/subscription_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Stripe.SubscriptionsTest do 2 | use Stripe.Case 3 | 4 | @tag :stripe_mock 5 | test ~f{&Stripe.Subscription.retrieve/2} do 6 | client = Stripe.new(api_key: "sk_test_123", base_url: "http://localhost:12111") 7 | 8 | assert {:ok, %Stripe.Subscription{} = subscription} = 9 | Stripe.Subscription.retrieve(client, "sub123", %{expand: [:customer]}) 10 | 11 | assert %Stripe.Customer{} = subscription.customer 12 | end 13 | 14 | describe ~f{&Stripe.Subscription.create/2} do 15 | @describetag :stripe_mock 16 | 17 | test "succeeds" do 18 | client = 19 | Stripe.new( 20 | api_key: "sk_test_123", 21 | base_url: "http://localhost:12111" 22 | ) 23 | 24 | assert {:ok, %Stripe.Subscription{}} = 25 | Stripe.Subscription.create(client, %{ 26 | customer: "cus_4QFJOjw2pOmAGJ", 27 | items: [ 28 | %{price: "price_1LnEPr2eZvKYlo2C8bVNzTbb"} 29 | ] 30 | }) 31 | end 32 | 33 | test "fails" do 34 | client = 35 | Stripe.new( 36 | api_key: "sk_test_123", 37 | base_url: "http://localhost:12111", 38 | base_backoff: 0 39 | ) 40 | 41 | assert {:error, %Stripe.ApiErrors{}} = Stripe.Subscription.create(client, %{}) 42 | end 43 | end 44 | 45 | describe ~f{&Stripe.Subscription.list/2} do 46 | @describetag :stripe_mock 47 | test "succeeds" do 48 | client = 49 | Stripe.new( 50 | api_key: "sk_test_123", 51 | base_url: "http://localhost:12111" 52 | ) 53 | 54 | assert {:ok, %Stripe.List{}} = Stripe.Subscription.list(client, %{status: "active"}) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /test/stripe/invoice_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Stripe.InvoiceTest do 2 | use Stripe.Case 3 | 4 | describe ~f{Stripe.Invoice} do 5 | test "exports functions" do 6 | assert [ 7 | {:__struct__, 0}, 8 | {:__struct__, 1}, 9 | {:create, 1}, 10 | {:create, 2}, 11 | {:create, 3}, 12 | {:delete, 2}, 13 | {:delete, 3}, 14 | {:finalize_invoice, 2}, 15 | {:finalize_invoice, 3}, 16 | {:finalize_invoice, 4}, 17 | {:list, 1}, 18 | {:list, 2}, 19 | {:list, 3}, 20 | {:mark_uncollectible, 2}, 21 | {:mark_uncollectible, 3}, 22 | {:mark_uncollectible, 4}, 23 | {:pay, 2}, 24 | {:pay, 3}, 25 | {:pay, 4}, 26 | {:retrieve, 2}, 27 | {:retrieve, 3}, 28 | {:retrieve, 4}, 29 | {:search, 1}, 30 | {:search, 2}, 31 | {:search, 3}, 32 | {:send_invoice, 2}, 33 | {:send_invoice, 3}, 34 | {:send_invoice, 4}, 35 | {:upcoming, 1}, 36 | {:upcoming, 2}, 37 | {:upcoming, 3}, 38 | {:upcoming_lines, 1}, 39 | {:upcoming_lines, 2}, 40 | {:upcoming_lines, 3}, 41 | {:update, 2}, 42 | {:update, 3}, 43 | {:update, 4}, 44 | {:void_invoice, 2}, 45 | {:void_invoice, 3}, 46 | {:void_invoice, 4} 47 | ] = Stripe.Invoice.__info__(:functions) 48 | end 49 | end 50 | 51 | @tag :stripe_mock 52 | test ~f{&Stripe.Invoice.retrieve/2} do 53 | client = Stripe.new(api_key: "sk_test_123", base_url: "http://localhost:12111") 54 | assert {:ok, _} = Stripe.Invoice.retrieve(client, "in") 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/stripe/telemetry.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.Telemetry do 2 | @moduledoc """ 3 | Telemetry integration. 4 | 5 | Unless specified, all times are in `:native` units. 6 | 7 | Stripe executes the following events: 8 | 9 | ### Request Start 10 | `[:stripe, :request, :start]` - Executed before an api call is made 11 | 12 | #### Measurements 13 | * `:system_time` - The system time. 14 | 15 | #### Metadata 16 | * `:attempt` - The number of attempts for this request 17 | * `:method` - The http method used 18 | * `:url` - The url used 19 | 20 | ### Request Stop 21 | `[:stripe, :request, :stop]` - Executed after an api call ended. 22 | 23 | #### Measurements 24 | * `:duration` - Time taken from the request start event. 25 | 26 | #### Metadata 27 | * `:attempt` - The number of attempts for this request 28 | * `:error` - The Stripe error if any 29 | * `:status` - The http status code 30 | * `:request_id` - Request ID returned by Stripe 31 | * `:result` -> `:ok` for succesful requests, `:error` otherwise 32 | * `:method` - The http method used 33 | * `:url` - The url used 34 | 35 | ### Request Exception 36 | `[:stripe, :request, :exception]` - Executed when an exception occurs while executing 37 | an api call. 38 | #### Measurements 39 | * `:duration` - The time it took since the start before raising the exception. 40 | 41 | #### Metadata 42 | 43 | * `:attempt` - The number of attempts for this request 44 | * `:method` - The http method used 45 | * `:url` - The url used 46 | * `:kind` - The type of exception. 47 | * `:reason` - Error description or error data. 48 | * `:stacktrace` - The stacktrace. 49 | 50 | 51 | 52 | """ 53 | @doc false 54 | # Used to easily create :start, :stop, :exception events. 55 | def span(event, start_metadata, fun) do 56 | :telemetry.span( 57 | [:stripe, event], 58 | start_metadata, 59 | fun 60 | ) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/stripe/http_client/httpc.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.HTTPClient.HTTPC do 2 | @moduledoc false 3 | @behaviour Stripe.HTTPClient 4 | 5 | @impl true 6 | def init() do 7 | {:ok, _} = Application.ensure_all_started(:inets) 8 | :ok 9 | end 10 | 11 | @impl true 12 | def request(:post = method, url, headers, body, opts) do 13 | headers = for {k, v} <- headers, do: {String.to_charlist(k), String.to_charlist(v)} 14 | request = {String.to_charlist(url), headers, ~C(application/x-www-form-urlencoded), body} 15 | 16 | do_request(method, request, opts) 17 | end 18 | 19 | def request(method, url, headers, _body, opts) do 20 | headers = for {k, v} <- headers, do: {String.to_charlist(k), String.to_charlist(v)} 21 | request = {String.to_charlist(url), headers} 22 | 23 | do_request(method, request, opts) 24 | end 25 | 26 | defp do_request(method, request, opts) do 27 | http_opts = 28 | [ 29 | ssl: http_ssl_opts(), 30 | timeout: opts[:timeout] || 10_000 31 | ] 32 | |> Keyword.merge(opts) 33 | 34 | case :httpc.request(method, request, http_opts, body_format: :binary) do 35 | {:ok, {{_, status, _}, headers, body}} -> 36 | headers = for {k, v} <- headers, do: {List.to_string(k), List.to_string(v)} 37 | 38 | {:ok, %{status: status, headers: headers, body: body}} 39 | 40 | {:error, error} -> 41 | {:error, error} 42 | end 43 | end 44 | 45 | # Load SSL certificates 46 | 47 | crt_file = CAStore.file_path() 48 | crt = File.read!(crt_file) 49 | pems = :public_key.pem_decode(crt) 50 | ders = Enum.map(pems, fn {:Certificate, der, _} -> der end) 51 | 52 | @cacerts ders 53 | 54 | defp http_ssl_opts() do 55 | # Use secure options, see https://gist.github.com/jonatanklosko/5e20ca84127f6b31bbe3906498e1a1d7 56 | [ 57 | verify: :verify_peer, 58 | cacerts: @cacerts, 59 | customize_hostname_check: [ 60 | match_fun: :public_key.pkix_verify_hostname_match_fun(:https) 61 | ] 62 | ] 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Striped 2 | ## [![Hex pm](http://img.shields.io/hexpm/v/striped.svg?style=flat)](https://hex.pm/packages/striped) [![Hex Docs](https://img.shields.io/badge/hex-docs-9768d1.svg)](https://hexdocs.pm/striped) [![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) ![.github/workflows/test.yml](https://github.com/maartenvanvliet/striped/actions/workflows/test.yml/badge.svg) 3 | 4 | 5 | Library to interface with the Stripe Api. Most of the code is generated from the [Stripe OpenApi](https://github.com/stripe/openapi) definitions. 6 | 7 | Inspiration was drawn from [Stripity Stripe](https://github.com/beam-community/stripity_stripe) and [openapi](https://github.com/wojtekmach/openapi). 8 | 9 | ## Installation 10 | 11 | ```elixir 12 | def deps do 13 | [ 14 | {:striped, "~> 0.5.0"} 15 | ] 16 | end 17 | ``` 18 | 19 | ## Usage 20 | 21 | ```elixir 22 | client = Stripe.new(api_key: "sk_test_123") 23 | {:ok, %Stripe.Customer{}} = Stripe.Customer.retrieve(client, "cus123") 24 | 25 | {:ok, %Stripe.Customer{}} = 26 | Stripe.Customer.create(client, %{ 27 | description: "Test description" 28 | }) 29 | 30 | ``` 31 | 32 | For the exact parameters you can consult the Stripe docs. 33 | 34 | ### Errors 35 | Stripe errors can be found in the `Stripe.ApiErrors` struct. 36 | Network errors etc. will be found in the error term. 37 | 38 | ```elixir 39 | {:error, %Stripe.ApiErrors{}} = 40 | Stripe.Customer.retrieve(client, "bogus") 41 | ``` 42 | 43 | ## Telemetry 44 | Stripe api calls made through this library emit Telemetry events. See the 45 | `Stripe.Telemetry` module for more information 46 | 47 | ### Api Version 48 | `Striped` uses the OpenApi definitions to build itself, so it 49 | uses the latest Api Version. You can however override the 50 | version by passing the `:version` option to the client. 51 | 52 | This SDK is generated for version: **__VERSION__** 53 | 54 | See https://stripe.com/docs/upgrades#__VERSION__ for breaking changes. 55 | 56 | ### Limitations 57 | 58 | * File Uploads currently don't work. 59 | * Connected Accounts are not supported yet. -------------------------------------------------------------------------------- /lib/stripe/open_api/phases/build_modules.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.OpenApi.Phases.BuildModules do 2 | @method_name_overrides %{ 3 | ["Account"] => %{ 4 | {"retrieve", "/v1/account"} => "retrieve", 5 | {"retrieve", "/v1/accounts/{account}"} => "retrieve_by_id" 6 | }, 7 | ["BankAccount"] => %{ 8 | {"delete", "/v1/customers/{customer}/sources/{id}"} => "delete_source", 9 | {"delete", "/v1/accounts/{account}/external_accounts/{id}"} => "delete_external_account", 10 | {"update", "/v1/customers/{customer}/sources/{id}"} => "update_source", 11 | {"update", "/v1/accounts/{account}/external_accounts/{id}"} => "update_external_account" 12 | }, 13 | ["Card"] => %{ 14 | {"delete", "/v1/customers/{customer}/sources/{id}"} => "delete_source", 15 | {"delete", "/v1/accounts/{account}/external_accounts/{id}"} => "delete_external_account", 16 | {"update", "/v1/customers/{customer}/sources/{id}"} => "update_source", 17 | {"update", "/v1/accounts/{account}/external_accounts/{id}"} => "update_external_account" 18 | } 19 | } 20 | 21 | @moduledoc false 22 | def run(blueprint, _options \\ []) do 23 | components = 24 | for {name, map} <- blueprint.source["components"]["schemas"], 25 | map["x-resourceId"] != nil || name == "api_errors", 26 | into: %{} do 27 | resource = 28 | (map["x-resourceId"] || name) |> String.split(".") |> Enum.map(&Macro.camelize/1) 29 | 30 | {name, 31 | %OpenApiGen.Blueprint.Schema{ 32 | name: name, 33 | description: fix_links(map["description"] || ""), 34 | operations: 35 | (map["x-stripeOperations"] || []) 36 | |> Enum.reject(&(&1["method_on"] == "collection")) 37 | |> Enum.map(&%{&1 | "method_name" => method_name(&1, resource)}), 38 | module: Module.concat(["Stripe" | resource]), 39 | properties: map["properties"] || %{}, 40 | expandable_fields: 41 | Map.get(map, "x-expandableFields", []) |> Enum.map(&String.to_atom/1) 42 | }} 43 | end 44 | 45 | {:ok, %{blueprint | components: components}} 46 | end 47 | 48 | defp method_name(op, resource) do 49 | case @method_name_overrides[resource][{op["method_name"], op["path"]}] do 50 | nil -> Macro.underscore(op["method_name"]) 51 | value -> value 52 | end 53 | end 54 | 55 | defp fix_links(docs) do 56 | Regex.replace(~r{\(/(docs|guides|radar)}m, docs, "(https://stripe.com/\\1") 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/stripe/customer_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Stripe.CustomerTest do 2 | use Stripe.Case 3 | 4 | test "exports functions" do 5 | assert [ 6 | {:__struct__, 0}, 7 | {:__struct__, 1}, 8 | {:balance_transactions, 2}, 9 | {:balance_transactions, 3}, 10 | {:balance_transactions, 4}, 11 | {:create, 1}, 12 | {:create, 2}, 13 | {:create, 3}, 14 | {:create_funding_instructions, 2}, 15 | {:create_funding_instructions, 3}, 16 | {:create_funding_instructions, 4}, 17 | {:delete, 2}, 18 | {:delete, 3}, 19 | {:delete_discount, 2}, 20 | {:delete_discount, 3}, 21 | {:fund_cash_balance, 2}, 22 | {:fund_cash_balance, 3}, 23 | {:fund_cash_balance, 4}, 24 | {:list, 1}, 25 | {:list, 2}, 26 | {:list, 3}, 27 | {:list_payment_methods, 2}, 28 | {:list_payment_methods, 3}, 29 | {:list_payment_methods, 4}, 30 | {:retrieve, 2}, 31 | {:retrieve, 3}, 32 | {:retrieve, 4}, 33 | {:retrieve_payment_method, 3}, 34 | {:retrieve_payment_method, 4}, 35 | {:retrieve_payment_method, 5}, 36 | {:search, 1}, 37 | {:search, 2}, 38 | {:search, 3}, 39 | {:update, 2}, 40 | {:update, 3}, 41 | {:update, 4} 42 | ] = Stripe.Customer.__info__(:functions) 43 | end 44 | 45 | @tag :stripe_mock 46 | test ~f{&Stripe.Customer.retrieve/2} do 47 | client = Stripe.new(api_key: "sk_test_123", base_url: "http://localhost:12111") 48 | 49 | assert {:ok, %Stripe.Customer{} = customer} = Stripe.Customer.retrieve(client, "sub123") 50 | assert %{id: "sub123"} = customer 51 | end 52 | 53 | describe ~f{&Stripe.Customer.create/2} do 54 | @describetag :stripe_mock 55 | test "succeeds" do 56 | client = 57 | Stripe.new( 58 | api_key: "sk_test_123", 59 | base_url: "http://localhost:12111" 60 | ) 61 | 62 | assert {:ok, %Stripe.Customer{}} = 63 | Stripe.Customer.create(client, %{ 64 | description: "Test description" 65 | }) 66 | end 67 | end 68 | 69 | describe ~f{&Stripe.Customer.delete/2} do 70 | @describetag :stripe_mock 71 | test "succeeds" do 72 | client = 73 | Stripe.new( 74 | api_key: "sk_test_123", 75 | base_url: "http://localhost:12111" 76 | ) 77 | 78 | assert {:ok, %Stripe.DeletedCustomer{}} = Stripe.Customer.delete(client, "cus_234") 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "castore": {:hex, :castore, "0.1.18", "deb5b9ab02400561b6f5708f3e7660fc35ca2d51bfc6a940d2f513f89c2975fc", [:mix], [], "hexpm", "61bbaf6452b782ef80b33cdb45701afbcf0a918a45ebe7e73f1130d661e66a06"}, 3 | "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, 4 | "ex_doc": {:hex, :ex_doc, "0.29.1", "b1c652fa5f92ee9cf15c75271168027f92039b3877094290a75abcaac82a9f77", [:mix], [{:earmark_parser, "~> 1.4.19", [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", "b7745fa6374a36daf484e2a2012274950e084815b936b1319aeebcf7809574f6"}, 5 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 6 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 7 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, 8 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 9 | "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"}, 10 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, 11 | "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, 12 | "uri_query": {:hex, :uri_query, "0.1.2", "ae35b83b472f3568c2c159eee3f3ccf585375d8a94fb5382db1ea3589e75c3b4", [:mix], [], "hexpm", "e3bc81816c98502c36498b9b2f239b89c71ce5eadfff7ceb2d6c0a2e6ae2ea0c"}, 13 | } 14 | -------------------------------------------------------------------------------- /test/integration/telemetry_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Stripe.TelemetryTest do 2 | use Stripe.Case, async: false 3 | 4 | import Mox 5 | 6 | setup do 7 | stub(TestClient, :init, fn -> :ok end) 8 | 9 | client = 10 | Stripe.new( 11 | api_key: "sk_test_123", 12 | http_client: TestClient, 13 | base_backoff: 0 14 | ) 15 | 16 | %{client: client} 17 | end 18 | 19 | defmodule RaisingClient do 20 | def init() do 21 | :ok 22 | end 23 | 24 | def request(:get, _, _, _, _) do 25 | raise "error" 26 | end 27 | end 28 | 29 | setup :verify_on_exit! 30 | 31 | describe "telemetry" do 32 | test "sends correct events", %{client: client} do 33 | attach_telemetry() 34 | 35 | expect(TestClient, :request, 3, fn _method, _url, _headers, _body, _opts -> 36 | {:ok, 37 | %{ 38 | status: 429, 39 | body: ~s|{ "error": {"type":"api_error", "code": "rate_limit"} }|, 40 | headers: [] 41 | }} 42 | end) 43 | 44 | assert {:error, %Stripe.ApiErrors{code: "rate_limit", type: "api_error"}} = 45 | Stripe.Subscription.retrieve(client, "sub123", %{expand: [:customer]}) 46 | 47 | assert_receive {[:stripe, :request, :start], _, 48 | %{ 49 | attempt: 1, 50 | method: :get, 51 | url: 52 | "https://api.stripe.com/v1/subscriptions/sub123?expand%5B0%5D=customer" 53 | }} 54 | 55 | assert_receive {[:stripe, :request, :stop], _, 56 | %{error: %Stripe.ApiErrors{code: "rate_limit"}, status: 429}} 57 | end 58 | 59 | test "sends exception event", %{client: client} do 60 | client = %{client | http_client: RaisingClient} 61 | attach_telemetry() 62 | 63 | assert_raise RuntimeError, fn -> 64 | {:error, %Stripe.ApiErrors{code: "rate_limit", type: "api_error"}} = 65 | Stripe.Subscription.retrieve(client, "sub123", %{expand: [:customer]}) 66 | end 67 | 68 | assert_receive {[:stripe, :request, :exception], _, 69 | %{ 70 | reason: _reason, 71 | stacktrace: _trace, 72 | attempt: 1, 73 | method: :get, 74 | url: 75 | "https://api.stripe.com/v1/subscriptions/sub123?expand%5B0%5D=customer" 76 | }} 77 | end 78 | end 79 | 80 | defp attach_telemetry() do 81 | name = "stripe_test" 82 | test_pid = self() 83 | 84 | :ok = 85 | :telemetry.attach_many( 86 | name, 87 | [ 88 | [:stripe, :request, :start], 89 | [:stripe, :request, :stop], 90 | [:stripe, :request, :exception] 91 | ], 92 | &Stripe.TelemetryTest.send_telemetry/4, 93 | %{test_pid: test_pid} 94 | ) 95 | 96 | ExUnit.Callbacks.on_exit(fn -> 97 | :telemetry.detach(name) 98 | end) 99 | end 100 | 101 | def send_telemetry(path, args, metadata, %{test_pid: pid}) do 102 | send(pid, {path, args, metadata}) 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/stripe/open_api.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.OpenApi do 2 | @moduledoc false 3 | 4 | alias Stripe.OpenApi 5 | 6 | defmacro __using__(opts) do 7 | opts = Keyword.put_new(opts, :base_module, Stripe) 8 | 9 | quote do 10 | @pipeline Stripe.OpenApi.pipeline(unquote(opts)) 11 | 12 | {:ok, blueprint} = Stripe.OpenApi.run(@pipeline) 13 | 14 | @version blueprint.api_version 15 | 16 | @typedoc "Stripe config" 17 | @type t :: %__MODULE__{ 18 | version: binary(), 19 | api_key: binary(), 20 | idempotency_key: nil | binary(), 21 | max_network_retries: pos_integer(), 22 | base_backoff: pos_integer, 23 | max_backoff: pos_integer, 24 | user_agent: binary(), 25 | base_url: binary(), 26 | http_client: term 27 | } 28 | 29 | defstruct [ 30 | :version, 31 | :api_key, 32 | idempotency_key: nil, 33 | max_network_retries: 3, 34 | base_backoff: 500, 35 | max_backoff: 2_000, 36 | user_agent: "striped", 37 | base_url: unquote(opts[:base_url]), 38 | http_client: Stripe.HTTPClient.HTTPC 39 | ] 40 | 41 | @doc """ 42 | Returns new client. 43 | 44 | #### Options 45 | 46 | * `:version` Set Stripe api version. All requests use your account API settings, unless you override the API version. 47 | * `:api_key` Set Stripe api keys. Test mode secret keys have the prefix `sk_test_` and live mode secret keys have the prefix `sk_live_`. 48 | * `:idempotency_key` Override default idempotency key 49 | * `:base_url` Override default base url. E.g. for local testing 50 | * `:http_client` Override http client, defaults to Stripe.HTTPClient.HTTPC. Must conform to Stripe.HTTPClient behaviour. 51 | 52 | #### Example 53 | ```elixir 54 | client = Stripe.new() 55 | Stripe.Customer.create(client, %{description: "a description"}) 56 | ``` 57 | """ 58 | @spec new(Keyword.t()) :: __MODULE__.t() 59 | def new(opts) do 60 | client = struct!(__MODULE__, opts) 61 | client.http_client.init() 62 | client 63 | end 64 | end 65 | end 66 | 67 | def pipeline(options \\ []) do 68 | [ 69 | {OpenApi.Phases.Parse, options}, 70 | {OpenApi.Phases.BuildModules, options}, 71 | {OpenApi.Phases.BuildOperations, options}, 72 | {OpenApi.Phases.BuildDocumentation, options}, 73 | {OpenApi.Phases.Compile, options}, 74 | {OpenApi.Phases.Version, options} 75 | ] 76 | end 77 | 78 | def run(pipeline) do 79 | {:ok, _} = 80 | pipeline 81 | |> List.flatten() 82 | |> run_phase(%OpenApiGen.Blueprint{}) 83 | end 84 | 85 | defp run_phase(pipeline, input) 86 | 87 | defp run_phase([], input) do 88 | {:ok, input} 89 | end 90 | 91 | defp run_phase([phase_config | todo], input) do 92 | {phase, options} = phase_invocation(phase_config) 93 | 94 | case phase.run(input, options) do 95 | {:ok, result} -> 96 | run_phase(todo, result) 97 | 98 | {:error, message} -> 99 | {:error, message} 100 | 101 | _ -> 102 | {:error, "Last phase did not return a valid result tuple."} 103 | end 104 | end 105 | 106 | defp phase_invocation({phase, options}) when is_list(options) do 107 | {phase, options} 108 | end 109 | 110 | defp phase_invocation(phase) do 111 | {phase, []} 112 | end 113 | end 114 | -------------------------------------------------------------------------------- /.github/workflows/codegen.yml: -------------------------------------------------------------------------------- 1 | name: stripe-codegen 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | schedule: 8 | ## Scheduled nightly at 00:23 9 | - cron: '23 0 * * *' 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-20.04 14 | name: Check if changed 15 | strategy: 16 | fail-fast: false 17 | 18 | outputs: 19 | current_tag: ${{ steps.current-tag.outputs.CURRENT_TAG }} 20 | latest_tag: ${{ steps.latest-tag.outputs.LATEST_TAG }} 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - name: Get tag used for generated files 25 | id: current-tag 26 | run: | 27 | # check if the file exist before 28 | [[ -f .latest-tag-stripe-openapi-sdk ]] && CURRENT_TAG=$(<.latest-tag-stripe-openapi-sdk) || CURRENT_TAG='' 29 | echo "::set-output name=CURRENT_TAG::${CURRENT_TAG}" 30 | - name: Get latest Stripe SDK tag 31 | id: latest-tag 32 | run: | 33 | wget https://api.github.com/repos/stripe/openapi/releases/latest 34 | tag_name=$(cat latest | jq -r '.tag_name') 35 | echo "::set-output name=LATEST_TAG::${tag_name}" 36 | generate: 37 | runs-on: ubuntu-20.04 38 | name: Update services 39 | needs: check 40 | if: ${{ needs.check.outputs.current_tag != needs.check.outputs.latest_tag }} 41 | 42 | env: 43 | LATEST_STRIPE_SDK_TAG: ${{ needs.check.outputs.latest_tag }} 44 | OTP_VERSION: "25.0" 45 | ELIXIR_VERSION: "1.14.0" 46 | 47 | strategy: 48 | fail-fast: false 49 | services: 50 | stripe-mock: 51 | image: stripe/stripe-mock:v0.144.0 52 | ports: 53 | - 12111:12111 54 | - 12112:12112 55 | steps: 56 | - uses: actions/checkout@v2 57 | 58 | - uses: erlef/setup-beam@v1 59 | with: 60 | otp-version: ${{ env.OTP_VERSION }} 61 | elixir-version: ${{ env.ELIXIR_VERSION }} 62 | 63 | - name: Checkout stripe/openapi (official OpenApi definitions) 64 | uses: actions/checkout@v2 65 | with: 66 | repository: stripe/openapi 67 | path: tmp/openapi 68 | ref: ${{ env.LATEST_STRIPE_SDK_TAG }} 69 | 70 | - name: Copy in OpenApi definitions 71 | run: cp tmp/openapi/openapi/spec3.sdk.json ./priv/openapi 72 | - name: Install Dependencies 73 | run: | 74 | mix local.rebar --force 75 | mix local.hex --force 76 | mix deps.get 77 | - name: Test generated code 78 | run: | 79 | mix test 80 | - name: Generate docs 81 | run: | 82 | mix docs 83 | - name: Update latest tag file 84 | run: | 85 | echo "${LATEST_STRIPE_SDK_TAG}" > .latest-tag-stripe-openapi-sdk 86 | - name: Commit files 87 | run: | 88 | git config --local user.email "noreply@github.com" 89 | git config --local user.name "github-actions[bot]" 90 | git add priv/openapi 91 | git add .latest-tag-stripe-openapi-sdk 92 | echo "Update services based on ${{ env.LATEST_STRIPE_SDK_TAG }} of Stripe OpenApi SDK" >> commit-msg 93 | echo >> commit-msg 94 | echo "Reference: https://github.com/stripe/openapi/releases/tag/${{ env.LATEST_STRIPE_SDK_TAG }}" >> commit-msg 95 | git commit -F commit-msg 96 | - name: Push changes 97 | uses: ad-m/github-push-action@master 98 | with: 99 | github_token: ${{ secrets.GITHUB_TOKEN }} 100 | branch: main -------------------------------------------------------------------------------- /test/integration/retries_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Stripe.RetriesTest do 2 | use Stripe.Case 3 | 4 | import Mox 5 | 6 | setup do 7 | stub(TestClient, :init, fn -> :ok end) 8 | 9 | client = 10 | Stripe.new( 11 | api_key: "sk_test_123", 12 | http_client: TestClient, 13 | base_backoff: 0, 14 | max_backoff: 100 15 | ) 16 | 17 | %{client: client} 18 | end 19 | 20 | setup :verify_on_exit! 21 | 22 | describe "retries" do 23 | test "retries request with `stripe-should-retry` true", %{client: client} do 24 | expect(TestClient, :request, 3, fn _method, _url, _headers, _body, _opts -> 25 | send_numbered_request!() 26 | 27 | {:ok, 28 | %{ 29 | status: 200, 30 | body: ~s|{ "object":"subscription" }|, 31 | headers: [{"stripe-should-retry", "true"}] 32 | }} 33 | end) 34 | 35 | assert {:ok, %Stripe.Subscription{}} = 36 | Stripe.Subscription.retrieve(client, "sub123", %{expand: [:customer]}) 37 | 38 | assert_receive {:request, 1} 39 | assert_receive {:request, 2} 40 | assert_receive {:request, 3} 41 | end 42 | 43 | test "does not retry request with `stripe-should-retry` false", %{client: client} do 44 | expect(TestClient, :request, 1, fn _method, _url, _headers, _body, _opts -> 45 | send_numbered_request!() 46 | 47 | {:ok, 48 | %{ 49 | status: 200, 50 | body: ~s|{ "object":"subscription" }|, 51 | headers: [{"stripe-should-retry", "false"}] 52 | }} 53 | end) 54 | 55 | assert {:ok, %Stripe.Subscription{}} = 56 | Stripe.Subscription.retrieve(client, "sub123", %{expand: [:customer]}) 57 | 58 | assert_receive {:request, 1} 59 | end 60 | 61 | test "retries request with status 429", %{client: client} do 62 | expect(TestClient, :request, 3, fn _method, _url, _headers, _body, _opts -> 63 | send_numbered_request!() 64 | 65 | {:ok, 66 | %{ 67 | status: 429, 68 | body: ~s|{ "error": {"type":"api_error", "code": "rate_limit"} }|, 69 | headers: [] 70 | }} 71 | end) 72 | 73 | assert {:error, %Stripe.ApiErrors{code: "rate_limit", type: "api_error"}} = 74 | Stripe.Subscription.retrieve(client, "sub123", %{expand: [:customer]}) 75 | 76 | assert_receive {:request, 1} 77 | assert_receive {:request, 2} 78 | assert_receive {:request, 3} 79 | end 80 | 81 | test "retries request with lock_timeout code", %{client: client} do 82 | expect(TestClient, :request, 3, fn _method, _url, _headers, _body, _opts -> 83 | send_numbered_request!() 84 | 85 | {:ok, 86 | %{ 87 | status: 429, 88 | body: ~s|{ "error": {"type":"api_error", "code": "lock_timeout"} }|, 89 | headers: [] 90 | }} 91 | end) 92 | 93 | assert {:error, %Stripe.ApiErrors{code: "lock_timeout", type: "api_error"}} = 94 | Stripe.Subscription.retrieve(client, "sub123", %{expand: [:customer]}) 95 | 96 | assert_receive {:request, 1} 97 | assert_receive {:request, 2} 98 | assert_receive {:request, 3} 99 | end 100 | 101 | test "retries request with network failure", %{client: client} do 102 | expect(TestClient, :request, 3, fn _method, _url, _headers, _body, _opts -> 103 | send_numbered_request!() 104 | 105 | { 106 | :error, 107 | {:failed_connect, 108 | [{:to_address, {'localhost', 12345}}, {:inet, [:inet], :econnrefused}]} 109 | } 110 | end) 111 | 112 | assert { 113 | :error, 114 | {:failed_connect, 115 | [{:to_address, {'localhost', 12345}}, {:inet, [:inet], :econnrefused}]} 116 | } = Stripe.Subscription.retrieve(client, "sub123", %{expand: [:customer]}) 117 | 118 | assert_receive {:request, 1} 119 | assert_receive {:request, 2} 120 | assert_receive {:request, 3} 121 | end 122 | end 123 | 124 | test "retries request max_network_retries times" do 125 | client = 126 | Stripe.new( 127 | api_key: "sk_test_123", 128 | http_client: TestClient, 129 | max_network_retries: 10, 130 | base_backoff: 0, 131 | max_backoff: 10 132 | ) 133 | 134 | expect(TestClient, :request, 10, fn _method, _url, _headers, _body, _opts -> 135 | send_numbered_request!() 136 | 137 | {:ok, 138 | %{ 139 | status: 200, 140 | body: ~s|{ "object":"subscription" }|, 141 | headers: [{"stripe-should-retry", "true"}] 142 | }} 143 | end) 144 | 145 | assert {:ok, %Stripe.Subscription{}} = 146 | Stripe.Subscription.retrieve(client, "sub123", %{expand: [:customer]}) 147 | end 148 | 149 | defp send_numbered_request! do 150 | n = Process.get(:n, 0) + 1 151 | Process.put(:n, n) 152 | send(self(), {:request, n}) 153 | end 154 | end 155 | -------------------------------------------------------------------------------- /lib/stripe/open_api/phases/build_operations.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.OpenApi.Phases.BuildOperations do 2 | @moduledoc false 3 | def run(blueprint, options \\ []) do 4 | operations = 5 | for {path, map} <- blueprint.source["paths"], 6 | !options[:only_paths] or path in options[:only_paths], 7 | !options[:path_prefixes] or String.starts_with?(path, options[:path_prefixes]), 8 | {method, map} <- map, 9 | into: %{} do 10 | name = 11 | map["operationId"] 12 | |> String.replace(["/", "-"], "_") 13 | |> Macro.underscore() 14 | |> String.to_atom() 15 | 16 | method = String.to_atom(method) 17 | 18 | parameters = parameters(map["parameters"]) 19 | 20 | path_params = 21 | parameters 22 | |> Enum.filter(&(&1.in == "path")) 23 | 24 | query_params = 25 | {:object, [], 26 | (map["parameters"] || []) 27 | |> Enum.filter(&(&1["in"] == "query")) 28 | |> Enum.map(fn param -> 29 | to_ast(param, name: param["name"], required: param["required"]) 30 | end)} 31 | 32 | # TODO handle file upload 33 | schema = 34 | map["requestBody"]["content"]["application/x-www-form-urlencoded"]["schema"] || 35 | map["requestBody"]["content"]["multipart/form-data"]["schema"] 36 | 37 | metadata = [function_name: name] 38 | 39 | body_parameters = 40 | {:object, metadata, 41 | Enum.map(Map.get(schema, "properties", %{}), fn {key, value} -> 42 | required = key in (schema["required"] || []) 43 | 44 | to_ast( 45 | value, 46 | Keyword.merge(metadata, 47 | name: key, 48 | required: required, 49 | description: schema["description"] 50 | ) 51 | ) 52 | end)} 53 | 54 | operation = %OpenApiGen.Blueprint.Operation{ 55 | id: map["operationId"], 56 | description: map["description"], 57 | deprecated: map["deprecated"] || false, 58 | method: method, 59 | name: name, 60 | parameters: parameters, 61 | path_parameters: path_params, 62 | query_parameters: query_params, 63 | body_parameters: body_parameters, 64 | path: path, 65 | success_response: 66 | response_type(map["responses"]["200"]["content"]["application/json"]["schema"]) 67 | } 68 | 69 | {{operation.path, operation.method}, operation} 70 | end 71 | 72 | blueprint = Map.put(blueprint, :operations, operations) 73 | {:ok, blueprint} 74 | end 75 | 76 | defp to_ast(value, metadata) 77 | 78 | defp to_ast(%{"type" => "array"} = schema, metadata) do 79 | { 80 | :array, 81 | [ 82 | name: build_name(metadata[:name]), 83 | # type: value["type"] && String.to_atom(value["type"] || :unknown), 84 | in: "body", 85 | required: false, 86 | description: schema["description"] 87 | ], 88 | [to_ast(schema["items"], metadata)] 89 | } 90 | end 91 | 92 | defp to_ast(%{"type" => "object"} = schema, metadata) do 93 | { 94 | :object, 95 | [ 96 | name: build_name(metadata[:name]), 97 | description: schema["description"] 98 | ], 99 | Enum.map(schema["properties"] || [], fn {key, value} -> 100 | required = key in (schema["required"] || []) 101 | 102 | to_ast(value, Keyword.merge(metadata, name: key, required: required)) 103 | end) 104 | } 105 | end 106 | 107 | defp to_ast(%{"type" => "string", "enum" => enum} = schema, metadata) when enum != [""] do 108 | { 109 | :string, 110 | [ 111 | name: build_name(metadata[:name]), 112 | description: schema["description"], 113 | enum: enum |> Enum.reject(&(&1 == "")) |> Enum.map(&String.to_atom/1) 114 | ], 115 | [] 116 | } 117 | end 118 | 119 | defp to_ast(%{"type" => type} = schema, metadata) do 120 | { 121 | String.to_atom(type), 122 | [ 123 | name: build_name(metadata[:name]), 124 | description: schema["description"] 125 | ], 126 | [] 127 | } 128 | end 129 | 130 | defp to_ast(%{"anyOf" => types} = schema, metadata) do 131 | { 132 | :any_of, 133 | [ 134 | name: build_name(metadata[:name]), 135 | description: schema["description"] 136 | ], 137 | Enum.map(types, &to_ast(&1, metadata)) 138 | } 139 | end 140 | 141 | defp to_ast(%{"schema" => schema} = _value, metadata) do 142 | to_ast(schema, Keyword.put(metadata, :description, schema["description"])) 143 | end 144 | 145 | defp build_name(nil) do 146 | nil 147 | end 148 | 149 | defp build_name(name) when is_binary(name) do 150 | String.to_atom(name) 151 | end 152 | 153 | defp build_name(name) when is_atom(name) do 154 | name 155 | end 156 | 157 | defp response_type(%{"$ref" => ref}), do: %OpenApiGen.Blueprint.Reference{name: ref} 158 | 159 | defp response_type(%{"anyOf" => any_of}), 160 | do: %OpenApiGen.Blueprint.AnyOf{any_of: Enum.map(any_of, &response_type/1)} 161 | 162 | defp response_type(%{ 163 | "properties" => %{ 164 | "object" => %{ 165 | "enum" => [ 166 | "search_result" 167 | ] 168 | }, 169 | "data" => %{"items" => items} 170 | } 171 | }) do 172 | %OpenApiGen.Blueprint.SearchResult{type_of: response_type(items)} 173 | end 174 | 175 | defp response_type(%{ 176 | "properties" => %{ 177 | "data" => %{"items" => items} 178 | } 179 | }), 180 | do: %OpenApiGen.Blueprint.ListOf{type_of: response_type(items)} 181 | 182 | defp response_type(val), do: val 183 | 184 | defp parameters(nil) do 185 | [] 186 | end 187 | 188 | defp parameters(params) do 189 | Enum.map( 190 | params, 191 | &%OpenApiGen.Blueprint.Parameter{ 192 | in: &1["in"], 193 | name: &1["name"], 194 | required: &1["required"], 195 | schema: build_schema(&1["schema"], &1["name"]) 196 | } 197 | ) 198 | end 199 | 200 | defp build_schema(schema, name) 201 | 202 | defp build_schema(%{"type" => type} = schema, name) 203 | when type in ["string", "integer", "boolean", "number"] do 204 | %OpenApiGen.Blueprint.Parameter.Schema{ 205 | type: schema["type"], 206 | name: name 207 | } 208 | end 209 | 210 | defp build_schema(%{"type" => "array"} = schema, name) do 211 | %OpenApiGen.Blueprint.Parameter.Schema{ 212 | type: schema["type"], 213 | items: build_schema(schema["items"], name), 214 | name: name 215 | } 216 | end 217 | 218 | defp build_schema(%{"type" => "object"} = schema, name) do 219 | %OpenApiGen.Blueprint.Parameter.Schema{ 220 | type: schema["type"], 221 | name: name, 222 | properties: 223 | (schema["properties"] || []) |> Enum.map(&build_schema(elem(&1, 1), elem(&1, 0))) 224 | } 225 | end 226 | 227 | defp build_schema(%{"anyOf" => any_of} = _schema, name) do 228 | %OpenApiGen.Blueprint.Parameter.Schema{ 229 | type: :any_of, 230 | any_of: any_of |> Enum.map(&build_schema(&1, name)), 231 | name: name 232 | } 233 | end 234 | end 235 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Striped.MixProject do 2 | use Mix.Project 3 | 4 | @url "https://github.com/maartenvanvliet/striped" 5 | 6 | def project do 7 | [ 8 | app: :striped, 9 | version: "0.5.0", 10 | elixir: "~> 1.14", 11 | elixirc_paths: elixirc_paths(Mix.env()), 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | source_url: @url, 15 | homepage_url: @url, 16 | name: "Striped", 17 | description: "Stripe Api SDK generated from OpenApi definitions.", 18 | package: [ 19 | maintainers: ["Maarten van Vliet"], 20 | licenses: ["MIT"], 21 | links: %{"GitHub" => @url}, 22 | files: ~w(LICENSE README.md lib priv mix.exs .formatter.exs) 23 | ], 24 | docs: [ 25 | main: "Stripe", 26 | source_url: @url, 27 | canonical: "http://hexdocs.pm/striped", 28 | annotations_for_docs: fn metadata -> (metadata[:generated] && [:generated]) || [] end, 29 | groups_for_modules: [ 30 | "Core Resources": [ 31 | Stripe.Balance, 32 | Stripe.BalanceTransaction, 33 | Stripe.Charge, 34 | Stripe.Customer, 35 | Stripe.Dispute, 36 | Stripe.Event, 37 | Stripe.ExchangeRate, 38 | Stripe.File, 39 | Stripe.FileLink, 40 | Stripe.Mandate, 41 | Stripe.PaymentIntent, 42 | Stripe.PaymentSource, 43 | Stripe.SetupIntent, 44 | Stripe.SetupAttempt, 45 | Stripe.Payout, 46 | Stripe.Refund, 47 | Stripe.Token 48 | ], 49 | "Payment Methods": [ 50 | Stripe.ApplePayDomain, 51 | Stripe.PaymentMethod, 52 | Stripe.BankAccount, 53 | Stripe.CashBalance, 54 | Stripe.Card, 55 | Stripe.Source, 56 | Stripe.SourceTransaction 57 | ], 58 | Products: [ 59 | Stripe.Product, 60 | Stripe.Price, 61 | Stripe.Coupon, 62 | Stripe.PromotionCode, 63 | Stripe.Discount, 64 | Stripe.Item, 65 | Stripe.TaxCode, 66 | Stripe.TaxId, 67 | Stripe.TaxRate, 68 | Stripe.ShippingRate 69 | ], 70 | Checkout: [ 71 | Stripe.Checkout.Session 72 | ], 73 | "Payment Links": [ 74 | Stripe.PaymentLink 75 | ], 76 | Billing: [ 77 | Stripe.BillingPortal.Configuration, 78 | Stripe.BillingPortal.Session, 79 | Stripe.CreditNote, 80 | Stripe.CreditNoteLineItem, 81 | Stripe.CustomerBalanceTransaction, 82 | Stripe.CustomerCashBalanceTransaction, 83 | Stripe.Invoice, 84 | Stripe.Invoiceitem, 85 | Stripe.LineItem, 86 | Stripe.Plan, 87 | Stripe.Quote, 88 | Stripe.Subscription, 89 | Stripe.SubscriptionItem, 90 | Stripe.SubscriptionSchedule, 91 | Stripe.TestHelpers.TestClock, 92 | Stripe.UsageRecord, 93 | Stripe.UsageRecordSummary 94 | ], 95 | Connect: [ 96 | Stripe.Account, 97 | Stripe.AccountLink, 98 | Stripe.ApplicationFee, 99 | Stripe.Capability, 100 | Stripe.CountrySpec, 101 | Stripe.ExternalAccount, 102 | Stripe.FeeRefund, 103 | Stripe.LoginLink, 104 | Stripe.Person, 105 | Stripe.Topup, 106 | Stripe.Transfer, 107 | Stripe.TransferReversal, 108 | Stripe.Apps.Secret 109 | ], 110 | Fraud: [ 111 | Stripe.Radar.EarlyFraudWarning, 112 | Stripe.Review, 113 | Stripe.Radar.ValueList, 114 | Stripe.Radar.ValueListItem 115 | ], 116 | Issuing: [ 117 | Stripe.EphemeralKey, 118 | Stripe.Issuing.Authorization, 119 | Stripe.Issuing.Cardholder, 120 | Stripe.Issuing.Card, 121 | Stripe.Issuing.Dispute, 122 | Stripe.FundingInstructions, 123 | Stripe.Issuing.Transaction 124 | ], 125 | Terminal: [ 126 | Stripe.Terminal.ConnectionToken, 127 | Stripe.Terminal.Location, 128 | Stripe.Terminal.Reader, 129 | Stripe.Terminal.Configuration 130 | ], 131 | Treasury: [ 132 | Stripe.Treasury.FinancialAccount, 133 | Stripe.Treasury.FinancialAccountFeatures, 134 | Stripe.Treasury.Transaction, 135 | Stripe.Treasury.TransactionEntry, 136 | Stripe.Treasury.OutboundTransfer, 137 | Stripe.Treasury.OutboundPayment, 138 | Stripe.Treasury.InboundTransfer, 139 | Stripe.Treasury.ReceivedCredit, 140 | Stripe.Treasury.ReceivedDebit, 141 | Stripe.Treasury.CreditReversal, 142 | Stripe.Treasury.DebitReversal 143 | ], 144 | Sigma: [ 145 | Stripe.ScheduledQueryRun 146 | ], 147 | Reporting: [ 148 | Stripe.Reporting.ReportRun, 149 | Stripe.Reporting.ReportType 150 | ], 151 | "Financial Connections": [ 152 | Stripe.FinancialConnections.Account, 153 | Stripe.FinancialConnections.AccountOwner, 154 | Stripe.FinancialConnections.Session 155 | ], 156 | Identify: [ 157 | Stripe.Identity.VerificationSession, 158 | Stripe.Identity.VerificationReport 159 | ], 160 | Webhooks: [ 161 | Stripe.WebhookEndpoint 162 | ], 163 | "Deleted Entities": [ 164 | Stripe.DeletedAccount, 165 | Stripe.DeletedApplePayDomain, 166 | Stripe.DeletedCoupon, 167 | Stripe.DeletedCustomer, 168 | Stripe.DeletedDiscount, 169 | Stripe.DeletedExternalAccount, 170 | Stripe.DeletedInvoice, 171 | Stripe.DeletedInvoiceitem, 172 | Stripe.DeletedPaymentSource, 173 | Stripe.DeletedPerson, 174 | Stripe.DeletedPlan, 175 | Stripe.DeletedProduct, 176 | Stripe.DeletedRadar.ValueList, 177 | Stripe.DeletedRadar.ValueListItem, 178 | Stripe.DeletedSubscriptionItem, 179 | Stripe.DeletedTaxId, 180 | Stripe.DeletedTerminal.Configuration, 181 | Stripe.DeletedTerminal.Location, 182 | Stripe.DeletedTerminal.Reader, 183 | Stripe.DeletedTestHelpers.TestClock, 184 | Stripe.DeletedWebhookEndpoint 185 | ] 186 | ] 187 | ] 188 | ] 189 | end 190 | 191 | # Run "mix help compile.app" to learn about applications. 192 | def application do 193 | [ 194 | extra_applications: [:logger, :inets, :public_key, :ssl] 195 | ] 196 | end 197 | 198 | defp elixirc_paths(:test), do: ["lib", "test/support"] 199 | defp elixirc_paths(_), do: ["lib"] 200 | # Run "mix help deps" to learn about dependencies. 201 | defp deps do 202 | [ 203 | {:jason, "~> 1.3.0"}, 204 | {:ex_doc, "~> 0.28"}, 205 | {:uri_query, "~> 0.1.2"}, 206 | {:mox, "~> 1.0", only: :test}, 207 | {:telemetry, "~> 1.1"}, 208 | {:castore, "~> 0.1.18", optional: true} 209 | ] 210 | end 211 | end 212 | -------------------------------------------------------------------------------- /lib/stripe.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe do 2 | use Stripe.OpenApi, 3 | path: 4 | [:code.priv_dir(:striped), "openapi", "spec3.sdk.json"] 5 | |> Path.join(), 6 | base_url: "https://api.stripe.com" 7 | 8 | @external_resource "README.md" 9 | @moduledoc @external_resource 10 | |> File.read!() 11 | |> String.split("") 12 | |> Enum.fetch!(1) 13 | |> String.replace("__VERSION__", @version) 14 | 15 | @doc """ 16 | Perform Stripe API requests. 17 | 18 | """ 19 | @spec request( 20 | method :: binary(), 21 | path :: binary(), 22 | client :: Stripe.t(), 23 | params :: map(), 24 | opts :: Keyword.t() 25 | ) :: {:ok, term} | {:error, Stripe.ApiErrors.t()} | {:error, term()} 26 | def request(method, path, client, params, opts \\ []) 27 | 28 | def request(method, path, client, params, opts) when method in [:get, :delete] do 29 | query = (params || %{}) |> UriQuery.params() |> URI.encode_query() 30 | url = URI.parse(client.base_url <> path) |> URI.append_query(query) |> URI.to_string() 31 | 32 | headers = build_headers(client) 33 | 34 | attempts = 1 35 | 36 | do_request(client, method, url, headers, "", attempts, opts) |> Tuple.delete_at(2) 37 | end 38 | 39 | def request(:post = method, path, client, params, opts) do 40 | url = client.base_url <> path 41 | 42 | headers = 43 | client 44 | |> build_headers() 45 | |> maybe_concat( 46 | ["Idempotency-Key: #{generate_idempotency_key()}"], 47 | client.idempotency_key == nil 48 | ) 49 | 50 | body = (params || %{}) |> UriQuery.params() |> URI.encode_query() 51 | 52 | attempts = 1 53 | 54 | {code, value, _opts} = do_request(client, method, url, headers, body, attempts, opts) 55 | {code, value} 56 | end 57 | 58 | defp do_request(client, method, url, headers, body, attempts, opts) do 59 | telemetry_metadata = %{attempt: attempts, method: method, url: url} 60 | http_opts = opts[:http_opts] || [] 61 | 62 | Stripe.Telemetry.span(:request, telemetry_metadata, fn -> 63 | result = 64 | case client.http_client.request(method, url, headers, body, http_opts) do 65 | {:ok, resp} -> 66 | decoded_body = Jason.decode!(resp.body) 67 | 68 | if should_retry?(resp, attempts, client.max_network_retries, decoded_body) do 69 | attempts 70 | |> backoff(client) 71 | |> :timer.sleep() 72 | 73 | do_request(client, method, url, headers, body, attempts + 1, opts) 74 | else 75 | case resp do 76 | %{status: status, headers: headers} when status >= 200 and status <= 299 -> 77 | {:ok, convert_value(decoded_body), 78 | %{request_id: extract_request_id(headers), status: status}} 79 | 80 | _ -> 81 | {:error, build_error(decoded_body), 82 | %{request_id: extract_request_id(headers), status: resp.status}} 83 | end 84 | end 85 | 86 | {:error, error} -> 87 | if should_retry?(%{}, attempts, client.max_network_retries) do 88 | do_request(client, method, url, headers, body, attempts + 1, opts) 89 | else 90 | {:error, error, %{}} 91 | end 92 | end 93 | 94 | extra_telemetry_metadata = 95 | case result do 96 | {:ok, _, extra} -> Map.put(extra, :result, :ok) 97 | {:error, error, extra} -> Map.merge(extra, %{result: :error, error: error}) 98 | end 99 | 100 | telemetry_metadata = Map.merge(telemetry_metadata, extra_telemetry_metadata) 101 | 102 | {result, telemetry_metadata} 103 | end) 104 | end 105 | 106 | def backoff(attempts, client) do 107 | base_backoff = client.base_backoff 108 | max_backoff = client.max_backoff 109 | 110 | (base_backoff * Integer.pow(2, attempts)) 111 | |> min(max_backoff) 112 | |> backoff_jitter() 113 | |> max(base_backoff) 114 | |> trunc() 115 | end 116 | 117 | defp backoff_jitter(n) do 118 | n * (0.5 * (1 + :rand.uniform())) 119 | end 120 | 121 | defp extract_request_id(headers) do 122 | List.keyfind(headers, "request-id", 0, {nil, nil}) |> elem(1) 123 | end 124 | 125 | defp should_retry?(_response, attempts, max_network_retries, decoded_body \\ %{}) 126 | 127 | defp should_retry?(_response, attempts, max_network_retries, _decoded_body) 128 | when attempts >= max_network_retries do 129 | false 130 | end 131 | 132 | defp should_retry?(%{status: 429}, _, _, _) do 133 | true 134 | end 135 | 136 | defp should_retry?(_response, _attempts, _max_network_retries, %{code: "lock_timeout"}) do 137 | true 138 | end 139 | 140 | defp should_retry?( 141 | %{headers: headers, status: status}, 142 | _attempts, 143 | _max_network_retries, 144 | _decoded_body 145 | ) 146 | when status >= 200 and status <= 499 do 147 | case headers |> List.keyfind("stripe-should-retry", 0, nil) do 148 | nil -> false 149 | {_, bool} -> String.to_atom(bool) 150 | end 151 | end 152 | 153 | defp should_retry?( 154 | %{headers: headers}, 155 | _attempts, 156 | _max_network_retries, 157 | _decoded_body 158 | ) do 159 | case headers |> List.keyfind("stripe-should-retry", 0, nil) do 160 | nil -> true 161 | {_, bool} -> String.to_atom(bool) 162 | end 163 | end 164 | 165 | defp should_retry?(_, _, _, _) do 166 | true 167 | end 168 | 169 | defp build_headers(client) do 170 | [ 171 | {"user-agent", client.user_agent}, 172 | {"authorization", "Bearer #{client.api_key}"} 173 | ] 174 | |> maybe_concat(["stripe-version: #{client.version}"], client.version != nil) 175 | end 176 | 177 | defp generate_idempotency_key do 178 | binary = << 179 | System.system_time(:nanosecond)::64, 180 | :erlang.phash2({node(), self()}, 16_777_216)::24, 181 | System.unique_integer([:positive])::32 182 | >> 183 | 184 | Base.hex_encode32(binary, case: :lower, padding: false) 185 | end 186 | 187 | defp maybe_concat(headers, _header, false), do: headers 188 | defp maybe_concat(headers, header, true), do: Enum.concat(headers, header) 189 | 190 | defp convert_map(value) do 191 | Enum.reduce(value, %{}, fn {key, value}, acc -> 192 | Map.put(acc, String.to_atom(key), convert_value(value)) 193 | end) 194 | end 195 | 196 | defp build_error(%{"error" => error}) do 197 | struct = Stripe.ApiErrors 198 | 199 | map = convert_map(error) 200 | struct!(struct, map) 201 | end 202 | 203 | defp convert_struct(struct, object) do 204 | struct_keys = Map.keys(struct.__struct__) |> List.delete(:__struct__) 205 | 206 | processed_map = 207 | struct_keys 208 | |> Enum.reduce(%{}, fn key, acc -> 209 | string_key = to_string(key) 210 | 211 | converted_value = 212 | case string_key do 213 | _ -> Map.get(object, string_key) |> convert_value() 214 | end 215 | 216 | Map.put(acc, key, converted_value) 217 | end) 218 | 219 | struct!(struct, processed_map) 220 | end 221 | 222 | defp convert_object(struct, object) do 223 | if known_struct?(struct) do 224 | convert_struct(struct, object) 225 | else 226 | convert_map(object) 227 | end 228 | end 229 | 230 | defp convert_value(%{"object" => type, "deleted" => _} = object) do 231 | type |> object_type_to_struct(deleted: true) |> convert_object(object) 232 | end 233 | 234 | defp convert_value(%{"object" => type} = object) do 235 | type |> object_type_to_struct() |> convert_object(object) 236 | end 237 | 238 | defp convert_value(map) when is_map(map) do 239 | convert_map(map) 240 | end 241 | 242 | defp convert_value(values) when is_list(values) do 243 | Enum.map(values, &convert_value/1) 244 | end 245 | 246 | defp convert_value(value) do 247 | value 248 | end 249 | 250 | defp known_struct?(struct) do 251 | function_exported?(struct, :__struct__, 0) 252 | end 253 | 254 | defp object_type_to_struct(object, opts \\ []) 255 | 256 | defp object_type_to_struct(object, deleted: true) do 257 | module = object |> String.split(".") |> Enum.map(&Macro.camelize/1) 258 | Module.concat(["Stripe", "Deleted#{module}"]) 259 | end 260 | 261 | defp object_type_to_struct(object, _) do 262 | module = object |> String.split(".") |> Enum.map(&Macro.camelize/1) 263 | Module.concat(["Stripe" | module]) 264 | end 265 | end 266 | -------------------------------------------------------------------------------- /lib/stripe/open_api/phases/compile.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe.OpenApi.Phases.Compile do 2 | @moduledoc false 3 | def run(blueprint, _options) do 4 | modules = Enum.map(blueprint.components, fn {_k, component} -> component.module end) 5 | 6 | for {_name, component} <- blueprint.components do 7 | funcs_types = 8 | for operation <- component.operations, 9 | operation_definition = 10 | lookup_operation( 11 | {operation["path"], String.to_atom(operation["operation"])}, 12 | blueprint.operations 13 | ), 14 | operation_definition != nil do 15 | arguments = 16 | operation_definition.path_parameters 17 | |> Enum.map(&String.to_atom(&1.name)) 18 | 19 | params? = 20 | match?({:object, _, [_ | _]}, operation_definition.query_parameters) || 21 | match?({:object, _, [_ | _]}, operation_definition.body_parameters) 22 | 23 | argument_names = 24 | arguments 25 | |> Enum.map(fn 26 | name -> 27 | Macro.var(name, __MODULE__) 28 | end) 29 | 30 | argument_values = 31 | arguments 32 | |> Enum.reject(&(&1 == :params)) 33 | |> Enum.map(fn name -> 34 | Macro.var(name, __MODULE__) 35 | end) 36 | 37 | argument_specs = 38 | arguments 39 | |> Enum.map(fn 40 | :params -> 41 | quote do 42 | params :: map() 43 | end 44 | 45 | name -> 46 | quote do 47 | unquote(Macro.var(name, __MODULE__)) :: binary() 48 | end 49 | end) 50 | 51 | function_name = String.to_atom(operation["method_name"]) 52 | 53 | success_response_spec = return_spec(operation_definition.success_response) 54 | 55 | params = 56 | cond do 57 | operation_definition.query_parameters != {:object, [], []} -> 58 | operation_definition.query_parameters 59 | 60 | operation_definition.body_parameters != {:object, [], []} -> 61 | operation_definition.body_parameters 62 | 63 | true -> 64 | [] 65 | end 66 | 67 | {param_specs, object_types} = unnest_object_types(params) 68 | 69 | object_types = MapSet.to_list(object_types) 70 | 71 | ast = 72 | quote do 73 | if unquote(operation_definition.deprecated) do 74 | @deprecated "Stripe has deprecated this operation" 75 | end 76 | 77 | @operation unquote(Macro.escape(operation_definition)) 78 | @doc unquote(operation_definition.description) 79 | @doc generated: true 80 | if unquote(params?) do 81 | @spec unquote(function_name)( 82 | client :: Stripe.t(), 83 | unquote_splicing(argument_specs), 84 | params :: unquote(to_inline_spec(param_specs)), 85 | opts :: Keyword.t() 86 | ) :: 87 | {:ok, unquote(success_response_spec)} 88 | | {:error, Stripe.ApiErrors.t()} 89 | | {:error, term()} 90 | 91 | def unquote(function_name)( 92 | client, 93 | unquote_splicing(argument_names), 94 | params \\ %{}, 95 | opts \\ [] 96 | ) do 97 | path = 98 | Stripe.OpenApi.Path.replace_path_params( 99 | @operation.path, 100 | @operation.path_parameters, 101 | unquote(argument_values) 102 | ) 103 | 104 | Stripe.request(@operation.method, path, client, params, opts) 105 | end 106 | else 107 | @spec unquote(function_name)( 108 | client :: Stripe.t(), 109 | unquote_splicing(argument_specs), 110 | opts :: Keyword.t() 111 | ) :: 112 | {:ok, unquote(success_response_spec)} 113 | | {:error, Stripe.ApiErrors.t()} 114 | | {:error, term()} 115 | def unquote(function_name)( 116 | client, 117 | unquote_splicing(argument_names), 118 | opts \\ [] 119 | ) do 120 | path = 121 | Stripe.OpenApi.Path.replace_path_params( 122 | @operation.path, 123 | @operation.path_parameters, 124 | unquote(argument_values) 125 | ) 126 | 127 | Stripe.request(@operation.method, path, client, %{}, opts) 128 | end 129 | end 130 | end 131 | 132 | {ast, object_types} 133 | end 134 | 135 | {funcs, types} = Enum.unzip(funcs_types) 136 | fields = component.properties |> Map.keys() |> Enum.map(&String.to_atom/1) 137 | 138 | # TODO fix uniq 139 | types = 140 | List.flatten(types) 141 | |> Enum.uniq_by(fn {_, meta, _} -> meta[:name] end) 142 | |> Enum.map(&to_type_spec/1) 143 | 144 | specs = 145 | Enum.map(component.properties, fn {key, value} -> 146 | {String.to_atom(key), build_spec(value, modules)} 147 | end) 148 | 149 | typedoc_fields = 150 | component.properties |> Enum.map_join("\n", fn {key, value} -> typedoc(key, value) end) 151 | 152 | typedoc = """ 153 | The `#{component.name}` type. 154 | 155 | #{typedoc_fields} 156 | """ 157 | 158 | body = 159 | quote do 160 | @moduledoc unquote(component.description) 161 | @moduledoc tags: :generated 162 | if unquote(fields) != nil do 163 | defstruct unquote(fields) 164 | 165 | @typedoc unquote(typedoc) 166 | @type t :: %__MODULE__{ 167 | unquote_splicing(specs) 168 | } 169 | end 170 | 171 | unquote_splicing(types) 172 | 173 | (unquote_splicing(funcs)) 174 | end 175 | 176 | Module.create(component.module, body, Macro.Env.location(__ENV__)) 177 | end 178 | 179 | {:ok, blueprint} 180 | end 181 | 182 | defp unnest_object_types(params) do 183 | Macro.postwalk(params, MapSet.new(), fn 184 | {:object, meta, children}, acc -> 185 | if meta[:name] == nil || children == [] do 186 | {{:object, meta, children}, acc} 187 | else 188 | {{:ref, [name: meta[:name]], []}, MapSet.put(acc, {:object, meta, children})} 189 | end 190 | 191 | other, acc -> 192 | {other, acc} 193 | end) 194 | end 195 | 196 | defp to_type_spec({:object, meta, children}) do 197 | specs = Enum.map(children, &to_spec_map/1) 198 | 199 | name = type_spec_name(meta[:name]) 200 | 201 | quote do 202 | @typedoc unquote(meta[:description]) 203 | @type unquote(Macro.var(name, __MODULE__)) :: %{ 204 | unquote_splicing(specs) 205 | } 206 | end 207 | end 208 | 209 | defp to_type_spec({:array, meta, [child]} = _ast) do 210 | name = type_spec_name(meta[:name]) 211 | 212 | quote do 213 | @typedoc unquote(meta[:description]) 214 | @type unquote(Macro.var(name, __MODULE__)) :: unquote(to_type(child)) 215 | end 216 | end 217 | 218 | defp to_type_spec({_, meta, children}) do 219 | specs = Enum.map(children, &to_spec_map/1) 220 | 221 | name = type_spec_name(meta[:name]) 222 | 223 | quote do 224 | @typedoc unquote(meta[:description]) 225 | @type unquote(Macro.var(name, __MODULE__)) :: %{ 226 | unquote_splicing(specs) 227 | } 228 | end 229 | end 230 | 231 | defp type_spec_name(name) do 232 | if name in [:reference] do 233 | :reference_0 234 | else 235 | name 236 | end 237 | end 238 | 239 | defp to_inline_spec({_, _meta, children}) do 240 | specs = Enum.map(children, &to_spec_map/1) 241 | 242 | quote do 243 | %{ 244 | unquote_splicing(specs) 245 | } 246 | end 247 | end 248 | 249 | defp to_spec_map({:array, meta, [_type]} = ast) do 250 | {to_name(meta), to_type(ast)} 251 | end 252 | 253 | defp to_spec_map({:any_of, meta, [type | tail]}) do 254 | {to_name(meta), 255 | quote do 256 | unquote(to_type(type)) | unquote(to_type(tail)) 257 | end} 258 | end 259 | 260 | defp to_spec_map({:ref, meta, _} = ast) do 261 | {to_name(meta), to_type(ast)} 262 | end 263 | 264 | defp to_spec_map({_type, meta, _children} = ast) do 265 | {to_name(meta), to_type(ast)} 266 | end 267 | 268 | defp to_name(meta) do 269 | if meta[:required] do 270 | meta[:name] 271 | else 272 | quote do 273 | optional(unquote(meta[:name])) 274 | end 275 | end 276 | end 277 | 278 | def to_type([type]) do 279 | quote do 280 | unquote(to_type(type)) 281 | end 282 | end 283 | 284 | def to_type([type | tail]) do 285 | quote do 286 | unquote(to_type(type)) | unquote(to_type(tail)) 287 | end 288 | end 289 | 290 | def to_type({:ref, meta, _}) do 291 | Macro.var(meta[:name], __MODULE__) 292 | end 293 | 294 | def to_type({:array, _meta, [type]}) do 295 | quote do 296 | list(unquote(to_type(type))) 297 | end 298 | end 299 | 300 | def to_type({:any_of, _, [type | tail]}) do 301 | quote do 302 | unquote(to_type(type)) | unquote(to_type(tail)) 303 | end 304 | end 305 | 306 | def to_type({:string, metadata, _}) do 307 | if metadata[:enum] do 308 | to_type(metadata[:enum]) 309 | else 310 | to_type(:string) 311 | end 312 | end 313 | 314 | def to_type({:object, metadata, _}) do 315 | if metadata[:name] == :metadata do 316 | quote do 317 | %{optional(binary) => binary} 318 | end 319 | else 320 | quote do 321 | map() 322 | end 323 | end 324 | end 325 | 326 | def to_type({type, _, _}) do 327 | to_type(type) 328 | end 329 | 330 | def to_type(type) when type in [:boolean, :number, :integer, :float] do 331 | quote do 332 | unquote(Macro.var(type, __MODULE__)) 333 | end 334 | end 335 | 336 | def to_type(:string) do 337 | quote do 338 | binary 339 | end 340 | end 341 | 342 | def to_type(type) do 343 | type 344 | end 345 | 346 | defp return_spec(%OpenApiGen.Blueprint.Reference{name: name}) do 347 | module = module_from_ref(name) 348 | 349 | quote do 350 | unquote(module).t() 351 | end 352 | end 353 | 354 | defp return_spec(%OpenApiGen.Blueprint.ListOf{type_of: type}) do 355 | quote do 356 | Stripe.List.t(unquote(return_spec(type))) 357 | end 358 | end 359 | 360 | defp return_spec(%OpenApiGen.Blueprint.SearchResult{type_of: type}) do 361 | quote do 362 | Stripe.SearchResult.t(unquote(return_spec(type))) 363 | end 364 | end 365 | 366 | defp return_spec(%{any_of: [type]} = _type) do 367 | return_spec(type) 368 | end 369 | 370 | defp return_spec(%OpenApiGen.Blueprint.AnyOf{any_of: [any_of | tail]} = type) do 371 | type = Map.put(type, :any_of, tail) 372 | {:|, [], [return_spec(any_of), return_spec(type)]} 373 | end 374 | 375 | defp return_spec(_) do 376 | [] 377 | end 378 | 379 | defp build_spec(%{"nullable" => true} = type, modules) do 380 | type = Map.delete(type, "nullable") 381 | {:|, [], [build_spec(type, modules), nil]} 382 | end 383 | 384 | defp build_spec(%{"anyOf" => [type]} = _type, modules) do 385 | build_spec(type, modules) 386 | end 387 | 388 | defp build_spec(%{"anyOf" => [any_of | tail]} = type, modules) do 389 | type = Map.put(type, "anyOf", tail) 390 | {:|, [], [build_spec(any_of, modules), build_spec(type, modules)]} 391 | end 392 | 393 | defp build_spec(%{"type" => "string"}, _) do 394 | quote do 395 | binary 396 | end 397 | end 398 | 399 | defp build_spec(%{"type" => "boolean"}, _) do 400 | quote do 401 | boolean 402 | end 403 | end 404 | 405 | defp build_spec(%{"type" => "integer"}, _) do 406 | quote do 407 | integer 408 | end 409 | end 410 | 411 | defp build_spec(%{"$ref" => ref}, modules) do 412 | module = module_from_ref(ref) 413 | 414 | if module in modules do 415 | quote do 416 | unquote(module).t() 417 | end 418 | else 419 | quote do 420 | term 421 | end 422 | end 423 | end 424 | 425 | defp build_spec(_, _) do 426 | quote do 427 | term 428 | end 429 | end 430 | 431 | defp module_from_ref(ref) do 432 | module = 433 | ref |> String.split("/") |> List.last() |> String.split(".") |> Enum.map(&Macro.camelize/1) 434 | 435 | Module.concat(["Stripe" | module]) 436 | end 437 | 438 | defp typedoc(field, props) do 439 | " * `#{field}` #{props["description"]}" 440 | end 441 | 442 | defp lookup_operation(path, operations) do 443 | Map.get(operations, path) 444 | end 445 | end 446 | --------------------------------------------------------------------------------