Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down Expand Up @@ -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: |
Expand Down
60 changes: 52 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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`.
Expand Down
57 changes: 57 additions & 0 deletions lib/postscript/event.ex
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion lib/postscript/helpers/url.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion lib/postscript/operation.ex
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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"],

Expand Down
51 changes: 51 additions & 0 deletions test/postscript/event_test.exs
Original file line number Diff line number Diff line change
@@ -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
44 changes: 44 additions & 0 deletions test/postscript_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()

Expand Down
Loading