defmodule CloudKit do @moduledoc """ Elixir client for the [Apple CloudKit API](https://developer.apple.com/documentation/cloudkit/). The public surface is intentionally small: CloudKit.query_records(%{recordType: "Items"}) CloudKit.fetch_record("record_name") CloudKit.create_record(%{recordType: "Items", fields: %{...}}) CloudKit.update_record("record_name", %{fields: %{...}}) CloudKit.delete_record("record_name") CloudKit.upload_asset("file_path", "record_type", "field_name") CloudKit.token() ## Configuration config :cloud_kit, container_id: System.get_env("CLOUDKIT_CONTAINER_ID"), environment: "development", # or "production" server_key_id: System.get_env("CLOUDKIT_SERVER_KEY_ID"), private_key: System.get_env("CLOUDKIT_PRIVATE_KEY"), base_url: "https://api.apple-cloudkit.com" Every function also accepts per-call `opts` that override the application config. """ alias CloudKit.{Asset, Client, ServerKey} @type opts :: keyword() @type response :: {:ok, map()} | {:error, term()} @doc "Return a cached-per-call CloudKit **access token** (after the JWT → token exchange)." @spec token(opts) :: {:ok, String.t()} | {:error, term()} def token(opts \\ []), do: ServerKey.access_token(opts) @doc """ Query records from the CloudKit database. ## Parameters - `query`: Map containing: - `recordType` - The record type to query - `filterBy` - Optional filter specification - `sortBy` - Optional sort specification - `limit` - Maximum records to return (default 200) - `desiredKeys` - Optional list of field names to return - `opts`: - `:zone_id` - Zone ID for record (defaults to default zone) ## Examples CloudKit.query_records(%{ recordType: "Items", filterBy: [%{fieldName: "name", comparator: "EQUALS", fieldValue: %{value: "Test"}}], limit: 50 }) """ @spec query_records(map(), opts) :: {:ok, map()} | {:error, term()} def query_records(query, opts \\ []) when is_map(query) do Client.post("/public/records/query", %{query: query}, opts) end @doc """ Fetch a single record by record name. ## Parameters - `record_name`: The unique record name - `opts`: - `:record_type` - Optional record type - `:zone_id` - Zone ID for record - `:desired_keys` - List of field names to return """ @spec fetch_record(String.t(), opts) :: response def fetch_record(record_name, opts \\ []) when is_binary(record_name) do body = %{ records: [%{recordName: record_name}] } body = if record_type = Keyword.get(opts, :record_type) do put_in(body, [:records, Access.at(0), :recordType], record_type) else body end Client.post("/public/records/lookup", body, opts) end @doc """ Create a new record. ## Parameters - `record`: Map containing: - `recordType` - The record type - `recordName` - Optional unique record name (generated if not provided) - `fields` - Map of field name to value - `opts`: - `:zone_id` - Zone ID for record ## Examples CloudKit.create_record(%{ recordType: "Items", fields: %{ name: %{value: "New Item"}, price: %{value: 99.99, type: "DOUBLE"} } }) """ @spec create_record(map(), opts) :: response def create_record(record, opts \\ []) when is_map(record) do Client.post( "/public/records/modify", %{operations: [%{action: "CREATE", record: record}]}, opts ) end @doc """ Update an existing record. ## Parameters - `record_name`: The unique record name - `updates`: Map of fields to update - `opts`: - `:record_type` - The record type - `:zone_id` - Zone ID for record ## Examples CloudKit.update_record("record_123", %{ fields: %{ price: %{value: 149.99, type: "DOUBLE"} } }) """ @spec update_record(String.t(), map(), opts) :: response def update_record(record_name, updates, opts \\ []) when is_binary(record_name) and is_map(updates) do record = Map.merge(updates, %{recordName: record_name}) record = if type = Keyword.get(opts, :record_type), do: Map.put(record, :recordType, type), else: record Client.post( "/public/records/modify", %{operations: [%{action: "UPDATE", record: record}]}, opts ) end @doc """ Delete a record by record name. ## Parameters - `record_name`: The unique record name - `opts`: - `:record_type` - The record type - `:zone_id` - Zone ID for record """ @spec delete_record(String.t(), opts) :: :ok | {:error, term()} def delete_record(record_name, opts \\ []) when is_binary(record_name) do record = %{recordName: record_name} record = if type = Keyword.get(opts, :record_type), do: Map.put(record, :recordType, type), else: record case Client.post( "/public/records/modify", %{operations: [%{action: "DELETE", record: record}]}, opts ) do {:ok, _} -> :ok error -> error end end @doc """ Upload an asset to CloudKit. ## Parameters - `file_path`: Path to the file to upload - `record_type`: The record type that will use this asset - `field_name`: The field name in the record - `opts`: - `:zone_id` - Zone ID - `:record_name` - Optional record name to associate ## Examples CloudKit.upload_asset("/path/to/image.png", "Items", "image") """ @spec upload_asset(String.t(), String.t(), String.t(), opts) :: response def upload_asset(file_path, record_type, field_name, opts \\ []) when is_binary(file_path) and is_binary(record_type) and is_binary(field_name) do Asset.upload(file_path, record_type, field_name, opts) end @doc """ Download an asset from CloudKit. ## Parameters - `download_url`: The download URL from an asset field - `opts`: Download options ## Examples {:ok, content} = CloudKit.download_asset(record["fields"]["image"]["downloadURL"]) """ @spec download_asset(String.t(), opts) :: {:ok, binary()} | {:error, term()} def download_asset(download_url, opts \\ []) when is_binary(download_url) do Asset.download(download_url, opts) end @doc """ List zones in the database. ## Parameters - `opts`: Optional configuration overrides """ @spec list_zones(opts) :: {:ok, list(map())} | {:error, term()} def list_zones(opts \\ []) do case Client.post("/public/zones/list", %{}, opts) do {:ok, %{"zones" => zones}} -> {:ok, zones} {:ok, other} -> {:ok, other} error -> error end end @doc """ Create a custom zone. ## Parameters - `zone_name`: The zone name - `opts`: Optional configuration """ @spec create_zone(String.t(), opts) :: response def create_zone(zone_name, opts \\ []) when is_binary(zone_name) do Client.post( "/public/zones/modify", %{ operations: [%{action: "CREATE", zone: %{zoneID: %{zoneName: zone_name}}}] }, opts ) end @doc """ Delete a zone. ## Parameters - `zone_name`: The zone name to delete - `opts`: Optional configuration """ @spec delete_zone(String.t(), opts) :: :ok | {:error, term()} def delete_zone(zone_name, opts \\ []) when is_binary(zone_name) do case Client.post( "/public/zones/modify", %{ operations: [%{action: "DELETE", zone: %{zoneID: %{zoneName: zone_name}}}] }, opts ) do {:ok, _} -> :ok error -> error end end @doc """ Create a subscription for change notifications. ## Parameters - `subscription`: Map containing: - `subscriptionType` - Type ("query" or "zone") - `recordType` - For query subscriptions, the record type - `filters` - Optional filters - `notification` - Notification configuration - `opts`: Optional configuration ## Examples CloudKit.create_subscription(%{ subscriptionType: "query", recordType: "Items", notification: %{ alertBody: "A new item was created" } }) """ @spec create_subscription(map(), opts) :: response def create_subscription(subscription, opts \\ []) when is_map(subscription) do Client.post( "/public/subscriptions/modify", %{ operations: [%{action: "CREATE", subscription: subscription}] }, opts ) end @doc """ List active subscriptions. ## Parameters - `opts`: Optional configuration """ @spec list_subscriptions(opts) :: {:ok, list(map())} | {:error, term()} def list_subscriptions(opts \\ []) do case Client.post("/public/subscriptions/list", %{}, opts) do {:ok, %{"subscriptions" => subs}} -> {:ok, subs} {:ok, other} -> {:ok, other} error -> error end end @doc """ Delete a subscription. ## Parameters - `subscription_id`: The subscription ID to delete - `opts`: Optional configuration """ @spec delete_subscription(String.t(), opts) :: :ok | {:error, term()} def delete_subscription(subscription_id, opts \\ []) when is_binary(subscription_id) do case Client.post( "/public/subscriptions/modify", %{ operations: [ %{action: "DELETE", subscription: %{subscriptionID: subscription_id}} ] }, opts ) do {:ok, _} -> :ok error -> error end end end