diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fa71ba9..491ca9f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -9,7 +9,7 @@ jobs: steps: - uses: actions/checkout@v4.2.2 - - uses: actions/cache@v4.1.2 + - uses: actions/cache@v4.3.0 with: key: ${{ github.job }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('mix.lock') }} path: | @@ -58,7 +58,7 @@ jobs: steps: - uses: actions/checkout@v4.2.2 - - uses: actions/cache@v4.1.2 + - uses: actions/cache@v4.3.0 with: key: ${{ github.job }}-${{ matrix.elixir }}-${{ matrix.otp }}-${{ hashFiles('mix.lock') }} path: | diff --git a/README.md b/README.md index b57d63b..e529b90 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,35 @@ ## Installation -`postscript` is published on Hex. Add it to your list of dependencies in `mix.exs`: +`postscript` is published as a **private** package under the Malomo Hex +organization. Add it to your list of dependencies in `mix.exs`: ```elixir defp deps do - { :postscript, "1.0.0" } + [ + {:postscript, "~> 1.1", organization: "malomo"} + ] end ``` -`postscript` require you to provide an HTTP client and JSON codec. `hackney` and -`jason` are used by default. If you wish to use these defaults you will also -need to specify `hackney` and `jason` as dependencies. +Local apps need Hex auth for the org (once per machine): + +```bash +mix hex.user auth +# or, for CI / build servers: +mix hex.organization auth malomo --key $HEX_MALOMO_KEY +``` + +`postscript` requires you to provide an HTTP client and JSON codec. `hackney` +and `jason` are used by default. If you wish to use these defaults you will +also need to specify `hackney` and `jason` as dependencies. + +### Publishing (Malomo maintainers) + +1. Create the Hex organization `malomo` at [hex.pm/dashboard](https://hex.pm/dashboard) if it does not exist (private packages require a paid org plan). +2. Authenticate as an org member: `mix hex.user auth` +3. From this repo: `mix hex.publish` (the `organization: "malomo"` package config targets the private repo). +4. Generate a CI key with `mix hex.organization key malomo generate` and store it as a secret for consumer apps. ## Usage @@ -26,7 +44,32 @@ a request that can be sent to the Postscript API using the Postscript.Keyword.list() |> Postscript.request(api_key: "...") ``` -For details on individual resource types [please see our document on HexDocs](https://hexdocs.pm/postscript/1.0.0/api-reference.html). +### Custom events (`/api/v2/events`) + +`Postscript.Event.create/1` builds a request for the Custom Events API. The +operation sets `http_path` to `"/api/v2"` automatically, so you do not need to +override the default `/v1` base path: + +```elixir +operation = + Postscript.Event.create( + type: "malomo_shipment_created", + email: "jason@gomalomo.com", + properties: %{"order_id" => "...", "order_number" => "..."}, + external_id: "seed:account-id:malomo_shipment_created" + ) + +Postscript.request(operation, + api_key: partner_key, + shop_token: shop_key +) +``` + +Existing `/v1` APIs (triggers, subscribers, keywords) are unchanged and continue +to use the default `http_path: "/v1"`. + +For details on individual resource types, see the HexDocs for the Malomo org +package after publishing (private docs require org auth). ## Configuration @@ -43,8 +86,9 @@ Possible configuration values are provided below: * `:http_host` - host used to send requests to. Defaults to `api.gopostscript.com`. * `:http_headers` - additional HTTP headers to send as part of the request. Defaults to `[]`. -* `:http_path` - path appended to the `:http_post` when sending a request. - Defaults to `/v1`. +* `:http_path` - path prepended to the operation path when sending a request. + Defaults to `/v1`. Ignored when the operation sets its own `http_path` + (as `Postscript.Event` does with `/api/v2`). * `:http_port` - HTTP port used when sending a request * `:http_protocol` - HTTP protocol used when sending a request. Defaults to `https`. diff --git a/lib/postscript/event.ex b/lib/postscript/event.ex new file mode 100644 index 0000000..5acb04c --- /dev/null +++ b/lib/postscript/event.ex @@ -0,0 +1,57 @@ +defmodule Postscript.Event do + @moduledoc """ + Operations for the Postscript Custom Events API (`POST /api/v2/events`). + + Event operations set `http_path` to `"/api/v2"` so they hit the Custom + Events API even though the client default is `"/v1"`. Callers do not need + to pass `http_path` to `Postscript.request/2`: + + operation = + Postscript.Event.create( + type: "malomo_shipment_created", + email: "jason@gomalomo.com", + properties: %{"order_id" => "..."}, + external_id: "seed:account-id:malomo_shipment_created" + ) + + Postscript.request(operation, + api_key: partner_key, + shop_token: shop_key + ) + + A successful create returns `202 Accepted` with an `event_ids` body, which + `Postscript.request/2` treats as `{:ok, response}`. + """ + + alias Postscript.{Operation} + + @doc """ + Create a custom event. + + Accepted options include: + + * `:type` (required) — 1–255 chars; letters, numbers, `_`, `:` only; + case-sensitive + * `:phone`, `:email`, or `:subscriber_id` — one subscriber identifier in the + body (unknown subscribers still return `202`; event is recorded, no + automation runs) + * `:properties` — renamed from v1 `data`; flat only (strings, numbers, + booleans, arrays; no nested objects); max 100 keys; keys lowercased by + Postscript; no keys starting with `_`; PII-like keys rejected + * `:occurred_at` — optional `YYYY-MM-DD HH:MM:SS.ffffff` + * `:external_id` — optional idempotency key (recommended on retries) + + Unrecognized top-level fields are rejected by Postscript with `400`. Put + extra metadata under `:properties`. + + The returned operation targets `/api/v2/events` automatically. + """ + @spec create(Keyword.t()) :: Operation.t() + def create(opts) do + %Operation{} + |> Map.put(:http_path, "/api/v2") + |> Map.put(:method, :post) + |> Map.put(:params, opts) + |> Map.put(:path, "/events") + end +end diff --git a/lib/postscript/helpers/url.ex b/lib/postscript/helpers/url.ex index d7f777a..4f2ba33 100644 --- a/lib/postscript/helpers/url.ex +++ b/lib/postscript/helpers/url.ex @@ -12,11 +12,13 @@ defmodule Postscript.Helpers.Url do @spec to_uri(Operation.t(), Config.t()) :: URI.t() def to_uri(operation, config) do + http_path = operation.http_path || config.http_path + %URI{} |> Map.put(:scheme, config.http_protocol) |> Map.put(:host, config.http_host) |> Map.put(:port, config.http_port) - |> Map.put(:path, "#{config.http_path}#{operation.path}") + |> Map.put(:path, "#{http_path}#{operation.path}") |> put_query(operation) end diff --git a/lib/postscript/operation.ex b/lib/postscript/operation.ex index 0e354a2..269294f 100644 --- a/lib/postscript/operation.ex +++ b/lib/postscript/operation.ex @@ -1,10 +1,11 @@ defmodule Postscript.Operation do @type t :: %__MODULE__{ + http_path: String.t() | nil, method: Postscript.http_method_t(), params: Keyword.t(), path: String.t() } - defstruct [method: nil, params: [], path: nil] + defstruct http_path: nil, method: nil, params: [], path: nil end diff --git a/mix.exs b/mix.exs index 0c6f241..494374c 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Postscript.MixProject do def project do [ app: :postscript, - version: "1.0.3", + version: "1.1.0", elixir: "~> 1.9", start_permanent: Mix.env() == :prod, deps: deps(), @@ -53,9 +53,14 @@ defmodule Postscript.MixProject do defp package do %{ + # Private Hex.org organization package. Publish with a Malomo org member + # account after creating the org at https://hex.pm/dashboard (paid seats + # required for private packages). Consumers must set organization: "malomo". + organization: "malomo", + description: "Elixir client for the Postscript API", - maintainers: ["Anthony Smith"], + maintainers: ["Jason Cartwright"], licenses: ["MIT"], diff --git a/test/postscript/event_test.exs b/test/postscript/event_test.exs new file mode 100644 index 0000000..b09873f --- /dev/null +++ b/test/postscript/event_test.exs @@ -0,0 +1,51 @@ +defmodule Postscript.EventTest do + use ExUnit.Case, async: true + + alias Postscript.{Event, Http, Operation, Response} + + test "create/1" do + opts = [ + type: "malomo_shipment_created", + email: "jason@gomalomo.com", + properties: %{"order_id" => "123"}, + external_id: "seed:account-id:malomo_shipment_created" + ] + + expected = %Operation{} + expected = Map.put(expected, :http_path, "/api/v2") + expected = Map.put(expected, :method, :post) + expected = Map.put(expected, :params, opts) + expected = Map.put(expected, :path, "/events") + + assert expected == Event.create(opts) + end + + test "create/1 targets /api/v2/events without a request http_path override" do + Http.Mock.start_link() + + Http.Mock.put_response( + {:ok, %{body: "{\"event_ids\":[\"evt_123\"]}", headers: [], status_code: 202}} + ) + + opts = [ + type: "malomo_shipment_created", + email: "jason@gomalomo.com", + properties: %{"order_id" => "123"} + ] + + result = + opts + |> Event.create() + |> Postscript.request(http_client: Http.Mock) + + assert "https://api.postscript.io/api/v2/events" == Http.Mock.get_request_url() + + assert Jason.decode!(Http.Mock.get_request_body()) == %{ + "type" => "malomo_shipment_created", + "email" => "jason@gomalomo.com", + "properties" => %{"order_id" => "123"} + } + + assert {:ok, %Response{body: %{"event_ids" => ["evt_123"]}, status_code: 202}} = result + end +end diff --git a/test/postscript_test.exs b/test/postscript_test.exs index 71a99de..68d0887 100644 --- a/test/postscript_test.exs +++ b/test/postscript_test.exs @@ -5,6 +5,12 @@ defmodule PostscriptTest do @ok_resp %{body: "{\"ok\":true}", headers: [], status_code: 200} + @accepted_resp %{ + body: "{\"event_ids\":[\"evt_123\"]}", + headers: [], + status_code: 202 + } + @not_ok_resp %{body: "{\"ok\":false}", headers: [], status_code: 400} test "sends the proper HTTP method" do @@ -63,6 +69,25 @@ defmodule PostscriptTest do assert "https://api.postscript.io/v1/fake" == Http.Mock.get_request_url() end + test "uses operation http_path over the default config http_path" do + Http.Mock.start_link() + + response = {:ok, @accepted_resp} + + Http.Mock.put_response(response) + + operation = %Operation{ + http_path: "/api/v2", + method: :post, + params: [type: "malomo_shipment_created"], + path: "/events" + } + + Postscript.request(operation, http_client: Http.Mock) + + assert "https://api.postscript.io/api/v2/events" == Http.Mock.get_request_url() + end + test "sends the proper HTTP headers" do Http.Mock.start_link() @@ -144,6 +169,25 @@ defmodule PostscriptTest do assert {:ok, %Response{}} = result end + test "returns :ok for 202 Accepted event responses" do + Http.Mock.start_link() + + response = {:ok, @accepted_resp} + + Http.Mock.put_response(response) + + operation = %Operation{ + http_path: "/api/v2", + method: :post, + params: [type: "malomo_shipment_created"], + path: "/events" + } + + result = Postscript.request(operation, http_client: Http.Mock) + + assert {:ok, %Response{body: %{"event_ids" => ["evt_123"]}, status_code: 202}} = result + end + test "returns :error when the request is not successful" do Http.Mock.start_link()