From 763122b16b875328f08084133f49143d0998adec Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Sat, 7 Feb 2026 09:35:47 -0600 Subject: [PATCH 1/6] feat: first pass at Elixir code generation target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Elixir/Req-based SDK generation support to the XDK generator. This includes templates, codegen module, and build integration. Testing will begin shortly — not ready for a PR. Amp-Thread-ID: https://ampcode.com/threads/T-019c38bc-09ad-7772-aabf-6ad3cbde05cb Co-authored-by: Amp --- Makefile | 20 +++- xdk-build/src/elixir.rs | 33 ++++++ xdk-build/src/main.rs | 60 ++++++++++ xdk-config.toml | 3 + xdk-gen/src/elixir/generator.rs | 64 +++++++++++ xdk-gen/src/elixir/mod.rs | 74 ++++++++++++ xdk-gen/src/lib.rs | 2 + xdk-gen/templates/elixir/application.j2 | 13 +++ xdk-gen/templates/elixir/client_class.j2 | 47 ++++++++ xdk-gen/templates/elixir/errors.j2 | 51 +++++++++ xdk-gen/templates/elixir/formatter.j2 | 3 + xdk-gen/templates/elixir/gitignore.j2 | 8 ++ xdk-gen/templates/elixir/main_client.j2 | 124 +++++++++++++++++++++ xdk-gen/templates/elixir/mix_exs.j2 | 29 +++++ xdk-gen/templates/elixir/readme.j2 | 29 +++++ xdk-gen/templates/elixir/test_helper.j2 | 1 + xdk-gen/templates/elixir/test_structure.j2 | 21 ++++ xdk-lib/src/templates.rs | 4 +- 18 files changed, 579 insertions(+), 7 deletions(-) create mode 100644 xdk-build/src/elixir.rs create mode 100644 xdk-gen/src/elixir/generator.rs create mode 100644 xdk-gen/src/elixir/mod.rs create mode 100644 xdk-gen/templates/elixir/application.j2 create mode 100644 xdk-gen/templates/elixir/client_class.j2 create mode 100644 xdk-gen/templates/elixir/errors.j2 create mode 100644 xdk-gen/templates/elixir/formatter.j2 create mode 100644 xdk-gen/templates/elixir/gitignore.j2 create mode 100644 xdk-gen/templates/elixir/main_client.j2 create mode 100644 xdk-gen/templates/elixir/mix_exs.j2 create mode 100644 xdk-gen/templates/elixir/readme.j2 create mode 100644 xdk-gen/templates/elixir/test_helper.j2 create mode 100644 xdk-gen/templates/elixir/test_structure.j2 diff --git a/Makefile b/Makefile index 11db50b5..9b91ce7c 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ # XDK SDK Generator .PHONY: all check build test clean help -.PHONY: generate python typescript -.PHONY: test-python test-typescript test-sdks +.PHONY: generate python typescript elixir +.PHONY: test-python test-typescript test-elixir test-sdks .PHONY: fmt clippy test-generator .PHONY: versions @@ -16,7 +16,7 @@ all: check test-generator # SDK Generation (local dev) # ===================================== -generate: python typescript +generate: python typescript elixir python: cargo run -- python --latest true @@ -24,11 +24,14 @@ python: typescript: cargo run -- typescript --latest true +elixir: + cargo run -- elixir --latest true + # ===================================== # SDK Testing (local dev) # ===================================== -test-sdks: test-python test-typescript +test-sdks: test-python test-typescript test-elixir test-python: python cd xdk/python && uv sync && uv run pytest tests/ -v @@ -36,6 +39,9 @@ test-python: python test-typescript: typescript cd xdk/typescript && npm ci && npm run build && npm run type-check && npm test +test-elixir: elixir + cd xdk/elixir && mix deps.get && mix test + # ===================================== # Generator # ===================================== @@ -61,14 +67,14 @@ test: test-generator test-sdks # ===================================== versions: - @grep -E "^(python|typescript) = " xdk-config.toml + @grep -E "^(python|typescript|elixir) = " xdk-config.toml # ===================================== # Cleanup # ===================================== clean: - rm -rf xdk/python xdk/typescript + rm -rf xdk/python xdk/typescript xdk/elixir cargo-clean: cargo clean @@ -85,6 +91,8 @@ help: @echo " make typescript Generate TypeScript SDK" @echo " make test-python Generate + test Python SDK" @echo " make test-typescript Generate + test TypeScript SDK" + @echo " make elixir Generate Elixir SDK" + @echo " make test-elixir Generate + test Elixir SDK" @echo "" @echo "Generator:" @echo " make check Run fmt + clippy" diff --git a/xdk-build/src/elixir.rs b/xdk-build/src/elixir.rs new file mode 100644 index 00000000..3c8eef52 --- /dev/null +++ b/xdk-build/src/elixir.rs @@ -0,0 +1,33 @@ +use crate::error::{BuildError, Result}; +use std::path::Path; +use xdk_gen::Elixir; +use xdk_lib::{XdkConfig, generate as generate_sdk, log_info, log_success}; +use xdk_openapi::OpenApi; + +pub fn generate(openapi: &OpenApi, output_dir: &Path) -> Result<()> { + log_info!("Generating Elixir SDK..."); + std::fs::create_dir_all(output_dir).map_err(BuildError::IoError)?; + + let config = XdkConfig::load_default().map_err(BuildError::SdkGenError)?; + let version = config.get_version("elixir").ok_or_else(|| { + BuildError::SdkGenError(xdk_lib::SdkGeneratorError::FrameworkError( + "Elixir version not found in config".to_string(), + )) + })?; + + generate_sdk(Elixir, openapi, output_dir, version).map_err(BuildError::SdkGenError)?; + + log_info!("Formatting generated Elixir files..."); + let status = std::process::Command::new("mix") + .arg("format") + .current_dir(output_dir) + .status(); + + match status { + Ok(s) if s.success() => log_success!("Elixir SDK formatted."), + _ => log_info!("Warning: mix format not available, skipping formatting"), + } + + log_success!("Elixir SDK generated in {}", output_dir.display()); + Ok(()) +} diff --git a/xdk-build/src/main.rs b/xdk-build/src/main.rs index 2581d90e..025240a4 100644 --- a/xdk-build/src/main.rs +++ b/xdk-build/src/main.rs @@ -2,6 +2,7 @@ #![allow(unused_imports)] // Declare modules +mod elixir; mod error; mod python; mod typescript; @@ -50,6 +51,19 @@ enum Commands { #[arg(short, long, default_value = "xdk/typescript")] output: PathBuf, }, + /// Generate an Elixir SDK from an OpenAPI specification + Elixir { + /// Path to the OpenAPI specification file + #[arg(short, long)] + spec: Option, + + #[arg(short, long)] + latest: Option, + + /// Output directory for the generated SDK + #[arg(short, long, default_value = "xdk/elixir")] + output: PathBuf, + }, } #[tokio::main] @@ -164,6 +178,52 @@ async fn main() -> Result<()> { // Call the generate method - `?` handles the Result conversion typescript::generate(&openapi, &output) } + Commands::Elixir { + spec, + output, + latest, + } => { + let openapi = if latest == Some(true) { + let client = reqwest::Client::new(); + let response = client + .get("https://api.x.com/2/openapi.json") + .send() + .await + .map_err(|e| { + BuildError::CommandFailed(format!("Failed to fetch OpenAPI spec: {}", e)) + })?; + + let json_text = response.text().await.map_err(|e| { + BuildError::CommandFailed(format!("Failed to read response: {}", e)) + })?; + + parse_json(&json_text).map_err(|e| SdkGeneratorError::from(e.to_string()))? + } else { + let extension = spec + .as_ref() + .unwrap() + .extension() + .and_then(|ext| ext.to_str()) + .ok_or_else(|| { + BuildError::CommandFailed("Invalid file extension".to_string()) + })?; + + match extension { + "yaml" | "yml" => parse_yaml_file(spec.as_ref().unwrap().to_str().unwrap()) + .map_err(|e| SdkGeneratorError::from(e.to_string()))?, + "json" => parse_json_file(spec.as_ref().unwrap().to_str().unwrap()) + .map_err(|e| SdkGeneratorError::from(e.to_string()))?, + _ => { + let err_msg = format!("Unsupported file extension: {}", extension); + return Err(BuildError::CommandFailed(err_msg)); + } + } + }; + + log_info!("Specification parsed successfully."); + + elixir::generate(&openapi, &output) + } }; // Handle the result with better error messaging diff --git a/xdk-config.toml b/xdk-config.toml index 25c0c6e5..5ac63f12 100644 --- a/xdk-config.toml +++ b/xdk-config.toml @@ -7,3 +7,6 @@ python = "0.5.0" # TypeScript SDK version typescript = "0.4.0" + +# Elixir SDK version +elixir = "0.1.0" diff --git a/xdk-gen/src/elixir/generator.rs b/xdk-gen/src/elixir/generator.rs new file mode 100644 index 00000000..a4a28ea5 --- /dev/null +++ b/xdk-gen/src/elixir/generator.rs @@ -0,0 +1,64 @@ +use xdk_lib::{Casing, language, pascal_case}; + +fn snake_case(value: &str) -> String { + Casing::Snake.convert_string(value) +} + +fn elixir_type(value: &str) -> String { + match value { + "string" => "String.t()", + "integer" => "integer()", + "number" => "float()", + "boolean" => "boolean()", + "array" => "list()", + "object" => "map()", + _ => "any()", + } + .to_string() +} + +fn last_part(value: &str) -> String { + value + .split('/') + .next_back() + .unwrap_or(value) + .split('.') + .next_back() + .unwrap_or(value) + .to_string() +} + +fn schema_name_from_ref(path: &str) -> String { + if path.starts_with("#/components/schemas/") { + path.trim_start_matches("#/components/schemas/").to_string() + } else { + path.split('/').next_back().unwrap_or(path).to_string() + } +} + +language! { + name: Elixir, + filters: [pascal_case, snake_case, elixir_type, last_part, schema_name_from_ref], + class_casing: Casing::Pascal, + operation_casing: Casing::Snake, + import_casing: Casing::Snake, + variable_casing: Casing::Snake, + render: [ + multiple { + render "client_class" => "lib/xdk/{}.ex" + }, + render "main_client" => "lib/xdk.ex", + render "application" => "lib/xdk/application.ex", + render "errors" => "lib/xdk/errors.ex", + render "mix_exs" => "mix.exs", + render "readme" => "README.md", + render "gitignore" => ".gitignore", + render "formatter" => ".formatter.exs" + ], + tests: [ + multiple { + render "test_structure" => "test/xdk/{}_test.exs" + }, + render "test_helper" => "test/test_helper.exs" + ] +} diff --git a/xdk-gen/src/elixir/mod.rs b/xdk-gen/src/elixir/mod.rs new file mode 100644 index 00000000..0f595a42 --- /dev/null +++ b/xdk-gen/src/elixir/mod.rs @@ -0,0 +1,74 @@ +mod generator; + +pub use generator::Elixir; + +#[cfg(test)] +mod tests { + use crate::elixir::generator::Elixir; + use std::fs; + use tempfile::Builder; + use xdk_lib::generator::generate; + use xdk_openapi::{OpenApiContextGuard, parse_json_file}; + + fn create_output_dir() -> std::path::PathBuf { + Builder::new() + .prefix("test_output_elixir") + .tempdir() + .expect("Failed to create temporary directory") + .path() + .to_path_buf() + } + + #[test] + fn test_generates_mix_exs() { + let output_dir = create_output_dir(); + let _guard = OpenApiContextGuard::new(); + let openapi = parse_json_file("../tests/openapi/simple.json").unwrap(); + + let result = generate(Elixir, &openapi, &output_dir, "0.1.0"); + assert!(result.is_ok(), "Failed to generate SDK: {:?}", result); + + assert!(output_dir.join("mix.exs").exists()); + assert!(output_dir.join("lib/xdk.ex").exists()); + assert!(output_dir.join("lib/xdk/application.ex").exists()); + assert!(output_dir.join("lib/xdk/errors.ex").exists()); + } + + #[test] + fn test_version_in_mix_exs() { + let output_dir = create_output_dir(); + let _guard = OpenApiContextGuard::new(); + let openapi = parse_json_file("../tests/openapi/simple.json").unwrap(); + + let test_version = "1.2.3-test"; + let result = generate(Elixir, &openapi, &output_dir, test_version); + assert!(result.is_ok(), "Failed to generate SDK: {:?}", result); + + let mix_exs = fs::read_to_string(output_dir.join("mix.exs")).unwrap(); + assert!( + mix_exs.contains("1.2.3-test"), + "mix.exs should contain version 1.2.3-test" + ); + } + + #[test] + fn test_user_agent_in_client() { + let output_dir = create_output_dir(); + let _guard = OpenApiContextGuard::new(); + let openapi = parse_json_file("../tests/openapi/simple.json").unwrap(); + + let test_version = "0.1.0"; + let result = generate(Elixir, &openapi, &output_dir, test_version); + assert!(result.is_ok(), "Failed to generate SDK: {:?}", result); + + let client = fs::read_to_string(output_dir.join("lib/xdk.ex")).unwrap(); + assert!( + client.contains("xdk-elixir/"), + "client should contain User-Agent prefix" + ); + assert!( + client.contains("@version \"0.1.0\""), + "client should contain @version module attribute with version" + ); + } +} diff --git a/xdk-gen/src/lib.rs b/xdk-gen/src/lib.rs index b07e1164..42602244 100644 --- a/xdk-gen/src/lib.rs +++ b/xdk-gen/src/lib.rs @@ -30,8 +30,10 @@ /// # Example /// /// See the `python` module for a reference implementation of a language generator. +pub use elixir::Elixir; pub use python::Python; pub use typescript::TypeScript; +mod elixir; mod python; mod typescript; diff --git a/xdk-gen/templates/elixir/application.j2 b/xdk-gen/templates/elixir/application.j2 new file mode 100644 index 00000000..e12f1e7a --- /dev/null +++ b/xdk-gen/templates/elixir/application.j2 @@ -0,0 +1,13 @@ +defmodule Xdk.Application do + @moduledoc false + use Application + + @impl true + def start(_type, _args) do + children = [ + {Finch, name: Xdk.Finch} + ] + + Supervisor.start_link(children, strategy: :one_for_one, name: Xdk.Supervisor) + end +end diff --git a/xdk-gen/templates/elixir/client_class.j2 b/xdk-gen/templates/elixir/client_class.j2 new file mode 100644 index 00000000..3ac19854 --- /dev/null +++ b/xdk-gen/templates/elixir/client_class.j2 @@ -0,0 +1,47 @@ +defmodule Xdk.{{ tag.class_name }} do + @moduledoc "Auto-generated client for {{ tag.display_name }} operations" +{% for operation in operations %} + + @doc """ + {{ operation.summary | default(value="") }} + + {{ operation.method | upper }} {{ operation.path }} + {% if operation.description %} + {{ operation.description }} + {% endif %} + """ + @spec {{ operation.method_name }}(Xdk.t() + {%- for param in operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'path') %}, {{ param.variable_name }} :: {{ param.param_type | elixir_type }}{% endfor %} + {%- if operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'query') | list %}, opts :: keyword(){% endif %} + ) :: {:ok, map()} | {:error, Exception.t()} + def {{ operation.method_name }}(client + {%- for param in operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'path') %}, {{ param.variable_name }}{% endfor %} + {%- if operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'query') | list %}, opts \\ []{% endif %} + ) do + {% set query_params = operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'query') | list %} + {% if query_params %} + query = + [ + {%- for param in query_params %} + {"{{ param.original_name }}", Keyword.get(opts, :{{ param.variable_name }})}{% if not loop.last %},{% endif %} + {%- endfor %} + ] + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + {% endif %} + + Xdk.request(client, :{{ operation.method | lower }}, "{{ operation.path }}"{% if operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'path') | list or query_params %}, + {% set path_params = operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'path') | list %} + {% if path_params %} + params: %{ + {%- for param in path_params %} + "{{ param.original_name }}" => {{ param.variable_name }}{% if not loop.last %},{% endif %} + {%- endfor %} + }{% if query_params %},{% endif %} + {% endif %} + {% if query_params %} + query: query + {% endif %} + {% endif %}) + end +{% endfor %} +end diff --git a/xdk-gen/templates/elixir/errors.j2 b/xdk-gen/templates/elixir/errors.j2 new file mode 100644 index 00000000..e59f82f5 --- /dev/null +++ b/xdk-gen/templates/elixir/errors.j2 @@ -0,0 +1,51 @@ +defmodule Xdk.Errors do + use Splode, + error_classes: [ + api: Xdk.Errors.Api, + transport: Xdk.Errors.Transport, + unknown: Xdk.Errors.Unknown + ], + unknown_error: Xdk.Errors.Unknown.UnknownError +end + +defmodule Xdk.Errors.Api do + use Splode.ErrorClass, class: :api +end + +defmodule Xdk.Errors.Transport do + use Splode.ErrorClass, class: :transport +end + +defmodule Xdk.Errors.Unknown do + use Splode.ErrorClass, class: :unknown +end + +defmodule Xdk.Errors.Unknown.UnknownError do + use Splode.Error, fields: [:error], class: :unknown + + def message(%{error: error}) do + if is_binary(error) do + error + else + inspect(error) + end + end +end + +{% raw %} +defmodule Xdk.Errors.ApiError do + use Splode.Error, fields: [:status, :body], class: :api + + def message(%{status: status, body: body}) do + "X API error (HTTP #{status}): #{inspect(body)}" + end +end + +defmodule Xdk.Errors.TransportError do + use Splode.Error, fields: [:reason], class: :transport + + def message(%{reason: reason}) do + "Transport error: #{inspect(reason)}" + end +end +{% endraw %} diff --git a/xdk-gen/templates/elixir/formatter.j2 b/xdk-gen/templates/elixir/formatter.j2 new file mode 100644 index 00000000..d304ff32 --- /dev/null +++ b/xdk-gen/templates/elixir/formatter.j2 @@ -0,0 +1,3 @@ +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/xdk-gen/templates/elixir/gitignore.j2 b/xdk-gen/templates/elixir/gitignore.j2 new file mode 100644 index 00000000..57708e02 --- /dev/null +++ b/xdk-gen/templates/elixir/gitignore.j2 @@ -0,0 +1,8 @@ +/_build/ +/deps/ +/doc/ +/.fetch +erl_crash.dump +*.ez +*.beam +/tmp/ diff --git a/xdk-gen/templates/elixir/main_client.j2 b/xdk-gen/templates/elixir/main_client.j2 new file mode 100644 index 00000000..e4f78134 --- /dev/null +++ b/xdk-gen/templates/elixir/main_client.j2 @@ -0,0 +1,124 @@ +defmodule Xdk do + @moduledoc """ + Auto-generated X API client. + + Uses Finch for HTTP transport and supports bearer token authentication. + """ + + @version "{{ version }}" + + defstruct [ + :base_url, + :finch, + :bearer, + :headers + ] + + @type t :: %__MODULE__{ + base_url: String.t(), + finch: atom(), + bearer: String.t() | nil, + headers: [{String.t(), String.t()}] + } + + @spec new(keyword()) :: t() + def new(opts \\ []) do + %__MODULE__{ + base_url: Keyword.get(opts, :base_url, "https://api.x.com"), + finch: Keyword.get(opts, :finch, Xdk.Finch), + bearer: Keyword.get(opts, :bearer), + headers: Keyword.get(opts, :headers, []) + } + end + + @spec request(t(), atom(), String.t(), keyword()) :: + {:ok, term()} | {:error, Exception.t()} + def request(%__MODULE__{} = client, method, path, opts \\ []) do + params = Keyword.get(opts, :params, %{}) + query = Keyword.get(opts, :query, []) + body = Keyword.get(opts, :json) + + url = client.base_url <> interpolate_path(path, params) + + url = + case query do + [] -> url + _ -> url <> "?" <> URI.encode_query(query) + end + +{% raw %} + headers = + [{"user-agent", "xdk-elixir/#{@version}"}] + |> Kernel.++(client.headers) + |> maybe_add_bearer(client.bearer) +{% endraw %} + + encoded_body = + case body do + nil -> nil + data -> Jason.encode!(data) + end + + headers = + if encoded_body do + [{"content-type", "application/json"} | headers] + else + headers + end + + method + |> Finch.build(url, headers, encoded_body) + |> Finch.request(client.finch) + |> handle_response() + end + +{% raw %} + defp interpolate_path(path, params) do + Enum.reduce(params, path, fn {k, v}, acc -> + String.replace(acc, "{#{k}}", URI.encode_www_form(to_string(v))) + end) + end + + defp maybe_add_bearer(headers, nil), do: headers + defp maybe_add_bearer(headers, token), do: [{"authorization", "Bearer #{token}"} | headers] +{% endraw %} + + defp handle_response({:ok, %Finch.Response{status: status, body: body, headers: headers}}) + when status in 200..299 do + case decode_body(body, headers) do + {:ok, decoded} -> {:ok, decoded} + {:error, reason} -> {:error, Xdk.Errors.Unknown.UnknownError.exception(error: reason)} + end + end + + defp handle_response({:ok, %Finch.Response{status: status, body: body, headers: headers}}) do + decoded = + case decode_body(body, headers) do + {:ok, d} -> d + _ -> body + end + + {:error, Xdk.Errors.ApiError.exception(status: status, body: decoded)} + end + + defp handle_response({:error, reason}) do + {:error, Xdk.Errors.TransportError.exception(reason: reason)} + end + + defp decode_body("", _headers), do: {:ok, nil} + defp decode_body(nil, _headers), do: {:ok, nil} + + defp decode_body(body, headers) do + content_type = + Enum.find_value(headers, "", fn + {"content-type", v} -> v + _ -> nil + end) + + if String.contains?(content_type, "application/json") do + Jason.decode(body) + else + {:ok, body} + end + end +end diff --git a/xdk-gen/templates/elixir/mix_exs.j2 b/xdk-gen/templates/elixir/mix_exs.j2 new file mode 100644 index 00000000..070d4ba7 --- /dev/null +++ b/xdk-gen/templates/elixir/mix_exs.j2 @@ -0,0 +1,29 @@ +defmodule Xdk.MixProject do + use Mix.Project + + def project do + [ + app: :xdk, + version: "{{ version }}", + elixir: "~> 1.15", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + def application do + [ + extra_applications: [:logger], + mod: {Xdk.Application, []} + ] + end + + defp deps do + [ + {:finch, "~> 0.19"}, + {:jason, "~> 1.4"}, + {:splode, "~> 0.2"}, + {:zoi, "~> 0.17"} + ] + end +end diff --git a/xdk-gen/templates/elixir/readme.j2 b/xdk-gen/templates/elixir/readme.j2 new file mode 100644 index 00000000..7447b7f4 --- /dev/null +++ b/xdk-gen/templates/elixir/readme.j2 @@ -0,0 +1,29 @@ +# XDK Elixir + +Auto-generated Elixir SDK for the X API. + +## Installation + +Add `xdk` to your list of dependencies in `mix.exs`: + +```elixir +def deps do + [ + {:xdk, "~> {{ version }}"} + ] +end +``` + +## Usage + +```elixir +# Create a client with bearer token authentication +client = Xdk.new(bearer: "YOUR_BEARER_TOKEN") + +# Make API calls +{:ok, result} = Xdk.Tweets.get_tweets(client) +``` + +## Version + +{{ version }} diff --git a/xdk-gen/templates/elixir/test_helper.j2 b/xdk-gen/templates/elixir/test_helper.j2 new file mode 100644 index 00000000..869559e7 --- /dev/null +++ b/xdk-gen/templates/elixir/test_helper.j2 @@ -0,0 +1 @@ +ExUnit.start() diff --git a/xdk-gen/templates/elixir/test_structure.j2 b/xdk-gen/templates/elixir/test_structure.j2 new file mode 100644 index 00000000..eae32b2b --- /dev/null +++ b/xdk-gen/templates/elixir/test_structure.j2 @@ -0,0 +1,21 @@ +defmodule Xdk.{{ tag.class_name }}Test do + use ExUnit.Case, async: true + + describe "module structure" do + test "module exists" do + assert Code.ensure_loaded?(Xdk.{{ tag.class_name }}) + end +{% for structural_test in test_spec.structural_tests %} +{% for method in structural_test.methods %} +{% set path_param_count = method.required_params | selectattr('location', 'equalto', 'path') | list | length %} +{% set has_query_params = method.required_params | selectattr('location', 'equalto', 'query') | list | length > 0 or method.optional_params | default(value=[]) | length > 0 %} + + test "{{ method.method_name }} function exists" do + Code.ensure_loaded!(Xdk.{{ tag.class_name }}) + assert function_exported?(Xdk.{{ tag.class_name }}, :{{ method.method_name }}, {{ path_param_count + 1 }}){% if has_query_params %} or function_exported?(Xdk.{{ tag.class_name }}, :{{ method.method_name }}, {{ path_param_count + 2 }}){% endif %} + + end +{% endfor %} +{% endfor %} + end +end diff --git a/xdk-lib/src/templates.rs b/xdk-lib/src/templates.rs index 03a70e29..e65cc4f1 100644 --- a/xdk-lib/src/templates.rs +++ b/xdk-lib/src/templates.rs @@ -75,6 +75,8 @@ fn get_header_for_file(template_name: &str, output_path: Option<&str>) -> String "markdown" } else if path.ends_with(".toml") { "toml" + } else if path.ends_with(".ex") || path.ends_with(".exs") { + "elixir" } else if path.ends_with("ignore") || path.ends_with(".gitignore") { "ignore" } else { @@ -87,7 +89,7 @@ fn get_header_for_file(template_name: &str, output_path: Option<&str>) -> String // Determine file type from template name if not determined from path let (comment_start, comment_line, comment_end) = - if file_type == "python" || file_type == "toml" || file_type == "ignore" { + if file_type == "python" || file_type == "toml" || file_type == "ignore" || file_type == "elixir" { ("", "#", "") } else if file_type == "typescript" { ("", "//", "") From 3ce9aa81d68d1a4d24aeed25a02c45e5dd8f4ed6 Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:25:34 -0600 Subject: [PATCH 2/6] feat(elixir): overhaul Elixir SDK generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove supervision tree (application.j2) — library no longer owns OTP processes - Remove unused Zoi dependency - Restructure errors: add RateLimitError, DecodeError, TransportError with context - Add Xdk.Query module for proper CSV list encoding in query params - Generate request body params for POST/PUT/DELETE endpoints (json: body) - Generate streaming functions using Finch.stream/5 for stream endpoints - Add Xdk.Streaming module for NDJSON stream consumption - Add Xdk.Paginator module for cursor-based pagination - Improve auth: support {:bearer, _} and {:oauth2, _}, per-request override - Fix typespecs: default to String.t() instead of any() for path params - Add Bypass-based integration tests (auth, error handling, rate limits) - Update .gitignore to exclude generated elixir output Amp-Thread-ID: https://ampcode.com/threads/T-019c38d2-bcb5-7195-927a-2787451bdcb5 Co-authored-by: Amp --- .gitignore | 1 + xdk-gen/src/elixir/generator.rs | 6 +- xdk-gen/src/elixir/mod.rs | 1 - xdk-gen/templates/elixir/application.j2 | 13 -- xdk-gen/templates/elixir/client_class.j2 | 42 +++- xdk-gen/templates/elixir/errors.j2 | 55 ++++-- xdk-gen/templates/elixir/main_client.j2 | 220 +++++++++++++++++---- xdk-gen/templates/elixir/mix_exs.j2 | 5 +- xdk-gen/templates/elixir/paginator.j2 | 63 ++++++ xdk-gen/templates/elixir/query.j2 | 20 ++ xdk-gen/templates/elixir/streaming.j2 | 90 +++++++++ xdk-gen/templates/elixir/test_helper.j2 | 19 ++ xdk-gen/templates/elixir/test_structure.j2 | 60 +++++- xdk-lib/src/testing.rs | 19 ++ xdk-lib/tests/testing_tests.rs | 3 + 15 files changed, 532 insertions(+), 85 deletions(-) delete mode 100644 xdk-gen/templates/elixir/application.j2 create mode 100644 xdk-gen/templates/elixir/paginator.j2 create mode 100644 xdk-gen/templates/elixir/query.j2 create mode 100644 xdk-gen/templates/elixir/streaming.j2 diff --git a/.gitignore b/.gitignore index cd94eac9..9acb8cd7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Generated SDKs (pushed to separate repos) /xdk/python/ /xdk/typescript/ +/xdk/elixir/ # Deploy keys (never commit) /.keys/ diff --git a/xdk-gen/src/elixir/generator.rs b/xdk-gen/src/elixir/generator.rs index a4a28ea5..fdff9486 100644 --- a/xdk-gen/src/elixir/generator.rs +++ b/xdk-gen/src/elixir/generator.rs @@ -12,7 +12,7 @@ fn elixir_type(value: &str) -> String { "boolean" => "boolean()", "array" => "list()", "object" => "map()", - _ => "any()", + _ => "String.t()", } .to_string() } @@ -48,8 +48,10 @@ language! { render "client_class" => "lib/xdk/{}.ex" }, render "main_client" => "lib/xdk.ex", - render "application" => "lib/xdk/application.ex", render "errors" => "lib/xdk/errors.ex", + render "query" => "lib/xdk/query.ex", + render "streaming" => "lib/xdk/streaming.ex", + render "paginator" => "lib/xdk/paginator.ex", render "mix_exs" => "mix.exs", render "readme" => "README.md", render "gitignore" => ".gitignore", diff --git a/xdk-gen/src/elixir/mod.rs b/xdk-gen/src/elixir/mod.rs index 0f595a42..c04cc79b 100644 --- a/xdk-gen/src/elixir/mod.rs +++ b/xdk-gen/src/elixir/mod.rs @@ -30,7 +30,6 @@ mod tests { assert!(output_dir.join("mix.exs").exists()); assert!(output_dir.join("lib/xdk.ex").exists()); - assert!(output_dir.join("lib/xdk/application.ex").exists()); assert!(output_dir.join("lib/xdk/errors.ex").exists()); } diff --git a/xdk-gen/templates/elixir/application.j2 b/xdk-gen/templates/elixir/application.j2 deleted file mode 100644 index e12f1e7a..00000000 --- a/xdk-gen/templates/elixir/application.j2 +++ /dev/null @@ -1,13 +0,0 @@ -defmodule Xdk.Application do - @moduledoc false - use Application - - @impl true - def start(_type, _args) do - children = [ - {Finch, name: Xdk.Finch} - ] - - Supervisor.start_link(children, strategy: :one_for_one, name: Xdk.Supervisor) - end -end diff --git a/xdk-gen/templates/elixir/client_class.j2 b/xdk-gen/templates/elixir/client_class.j2 index 3ac19854..dd9117f9 100644 --- a/xdk-gen/templates/elixir/client_class.j2 +++ b/xdk-gen/templates/elixir/client_class.j2 @@ -1,6 +1,9 @@ defmodule Xdk.{{ tag.class_name }} do @moduledoc "Auto-generated client for {{ tag.display_name }} operations" {% for operation in operations %} + {% set path_params = operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'path') | list %} + {% set query_params = operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'query') | list %} + {% set has_body = operation.request_body is defined and operation.request_body %} @doc """ {{ operation.summary | default(value="") }} @@ -11,14 +14,16 @@ defmodule Xdk.{{ tag.class_name }} do {% endif %} """ @spec {{ operation.method_name }}(Xdk.t() - {%- for param in operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'path') %}, {{ param.variable_name }} :: {{ param.param_type | elixir_type }}{% endfor %} - {%- if operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'query') | list %}, opts :: keyword(){% endif %} - ) :: {:ok, map()} | {:error, Exception.t()} + {%- for param in path_params %}, {{ param.variable_name }} :: {{ param.param_type | elixir_type }}{% endfor %} + {%- if has_body %}, body :: map(){% endif %} + {%- if query_params %}, opts :: keyword(){% endif %} + ) :: {% if operation.is_streaming %}Enumerable.t(){% else %}{:ok, map()} | {:error, Xdk.Errors.error()}{% endif %} + def {{ operation.method_name }}(client - {%- for param in operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'path') %}, {{ param.variable_name }}{% endfor %} - {%- if operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'query') | list %}, opts \\ []{% endif %} + {%- for param in path_params %}, {{ param.variable_name }}{% endfor %} + {%- if has_body %}, body{% endif %} + {%- if query_params %}, opts \\ []{% endif %} ) do - {% set query_params = operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'query') | list %} {% if query_params %} query = [ @@ -26,11 +31,12 @@ defmodule Xdk.{{ tag.class_name }} do {"{{ param.original_name }}", Keyword.get(opts, :{{ param.variable_name }})}{% if not loop.last %},{% endif %} {%- endfor %} ] - |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Xdk.Query.build() {% endif %} - Xdk.request(client, :{{ operation.method | lower }}, "{{ operation.path }}"{% if operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'path') | list or query_params %}, - {% set path_params = operation.parameters | default(value=[]) | selectattr('location', 'equalto', 'path') | list %} + {% if operation.is_streaming %} + Xdk.Streaming.ndjson_stream(client, :{{ operation.method | lower }}, "{{ operation.path }}" + {%- if path_params or query_params %}, {% if path_params %} params: %{ {%- for param in path_params %} @@ -42,6 +48,24 @@ defmodule Xdk.{{ tag.class_name }} do query: query {% endif %} {% endif %}) + {% else %} + Xdk.request(client, :{{ operation.method | lower }}, "{{ operation.path }}" + {%- if path_params or query_params or has_body %}, + {% if path_params %} + params: %{ + {%- for param in path_params %} + "{{ param.original_name }}" => {{ param.variable_name }}{% if not loop.last %},{% endif %} + {%- endfor %} + }{% if query_params or has_body %},{% endif %} + {% endif %} + {% if query_params %} + query: query{% if has_body %},{% endif %} + {% endif %} + {% if has_body %} + json: body + {% endif %} + {% endif %}) + {% endif %} end {% endfor %} end diff --git a/xdk-gen/templates/elixir/errors.j2 b/xdk-gen/templates/elixir/errors.j2 index e59f82f5..c383300e 100644 --- a/xdk-gen/templates/elixir/errors.j2 +++ b/xdk-gen/templates/elixir/errors.j2 @@ -3,9 +3,16 @@ defmodule Xdk.Errors do error_classes: [ api: Xdk.Errors.Api, transport: Xdk.Errors.Transport, - unknown: Xdk.Errors.Unknown + decode: Xdk.Errors.Decode ], - unknown_error: Xdk.Errors.Unknown.UnknownError + unknown_error: Xdk.Errors.UnknownError + + @type error :: + Xdk.Errors.ApiError.t() + | Xdk.Errors.RateLimitError.t() + | Xdk.Errors.TransportError.t() + | Xdk.Errors.DecodeError.t() + | Xdk.Errors.UnknownError.t() end defmodule Xdk.Errors.Api do @@ -16,31 +23,29 @@ defmodule Xdk.Errors.Transport do use Splode.ErrorClass, class: :transport end -defmodule Xdk.Errors.Unknown do - use Splode.ErrorClass, class: :unknown -end - -defmodule Xdk.Errors.Unknown.UnknownError do - use Splode.Error, fields: [:error], class: :unknown - - def message(%{error: error}) do - if is_binary(error) do - error - else - inspect(error) - end - end +defmodule Xdk.Errors.Decode do + use Splode.ErrorClass, class: :decode end {% raw %} defmodule Xdk.Errors.ApiError do - use Splode.Error, fields: [:status, :body], class: :api + use Splode.Error, fields: [:status, :body, :headers], class: :api def message(%{status: status, body: body}) do "X API error (HTTP #{status}): #{inspect(body)}" end end +defmodule Xdk.Errors.RateLimitError do + use Splode.Error, + fields: [:status, :body, :headers, :limit, :remaining, :reset, :retry_after_ms], + class: :api + + def message(%{status: status, retry_after_ms: ms}) do + "X API rate limit exceeded (HTTP #{status}), retry after #{ms}ms" + end +end + defmodule Xdk.Errors.TransportError do use Splode.Error, fields: [:reason], class: :transport @@ -48,4 +53,20 @@ defmodule Xdk.Errors.TransportError do "Transport error: #{inspect(reason)}" end end + +defmodule Xdk.Errors.DecodeError do + use Splode.Error, fields: [:error, :raw_body], class: :decode + + def message(%{error: error}) do + "JSON decode error: #{inspect(error)}" + end +end + +defmodule Xdk.Errors.UnknownError do + use Splode.Error, fields: [:error], class: :decode + + def message(%{error: error}) do + if is_binary(error), do: error, else: inspect(error) + end +end {% endraw %} diff --git a/xdk-gen/templates/elixir/main_client.j2 b/xdk-gen/templates/elixir/main_client.j2 index e4f78134..458c2b7f 100644 --- a/xdk-gen/templates/elixir/main_client.j2 +++ b/xdk-gen/templates/elixir/main_client.j2 @@ -10,108 +10,218 @@ defmodule Xdk do defstruct [ :base_url, :finch, - :bearer, - :headers + :auth, + :headers, + :request_opts ] + @type auth :: nil | {:bearer, String.t()} | {:oauth2, map()} + @type t :: %__MODULE__{ base_url: String.t(), - finch: atom(), - bearer: String.t() | nil, - headers: [{String.t(), String.t()}] + finch: atom() | nil, + auth: auth(), + headers: [{String.t(), String.t()}], + request_opts: keyword() } @spec new(keyword()) :: t() def new(opts \\ []) do + defaults = Application.get_env(:xdk, :default_config, []) + opts = Keyword.merge(defaults, opts) + %__MODULE__{ base_url: Keyword.get(opts, :base_url, "https://api.x.com"), - finch: Keyword.get(opts, :finch, Xdk.Finch), - bearer: Keyword.get(opts, :bearer), - headers: Keyword.get(opts, :headers, []) + finch: Keyword.get(opts, :finch), + auth: resolve_auth(opts), + headers: Keyword.get(opts, :headers, []), + request_opts: Keyword.get(opts, :request_opts, []) } end +{% raw %} + @doc """ + Returns a child spec for a Finch pool suitable for the X API. + + Add to your supervision tree: + + children = [ + Xdk.child_spec(name: MyApp.XFinch), + ... + ] + + Then: `Xdk.new(finch: MyApp.XFinch, auth: {:bearer, token})` + """ + @spec child_spec(keyword()) :: Supervisor.child_spec() + def child_spec(opts) do + name = Keyword.fetch!(opts, :name) + pool_opts = Keyword.get(opts, :pools, %{}) + Finch.child_spec(name: name, pools: pool_opts) + end + @spec request(t(), atom(), String.t(), keyword()) :: - {:ok, term()} | {:error, Exception.t()} + {:ok, term()} | {:error, Xdk.Errors.error()} def request(%__MODULE__{} = client, method, path, opts \\ []) do params = Keyword.get(opts, :params, %{}) query = Keyword.get(opts, :query, []) - body = Keyword.get(opts, :json) + json = Keyword.get(opts, :json) + raw_body = Keyword.get(opts, :body) url = client.base_url <> interpolate_path(path, params) url = case query do [] -> url - _ -> url <> "?" <> URI.encode_query(query) + _ -> url <> "?" <> Xdk.Query.encode(query) end -{% raw %} - headers = - [{"user-agent", "xdk-elixir/#{@version}"}] - |> Kernel.++(client.headers) - |> maybe_add_bearer(client.bearer) -{% endraw %} + headers = build_headers(client, opts) - encoded_body = - case body do - nil -> nil - data -> Jason.encode!(data) - end + {encoded_body, headers} = + cond do + json != nil -> + case Jason.encode(json) do + {:ok, bin} -> {bin, [{"content-type", "application/json"} | headers]} + {:error, err} -> throw({:encode_error, err}) + end + + raw_body != nil -> + {raw_body, headers} - headers = - if encoded_body do - [{"content-type", "application/json"} | headers] - else - headers + true -> + {nil, headers} end + finch = require_finch!(client) + method |> Finch.build(url, headers, encoded_body) - |> Finch.request(client.finch) + |> Finch.request(finch, client.request_opts) |> handle_response() + catch + {:encode_error, err} -> + {:error, Xdk.Errors.DecodeError.exception(error: err, raw_body: nil)} + end + + @doc false + def require_finch!(%__MODULE__{finch: nil}) do + raise ArgumentError, """ + Xdk requires a Finch pool name. + + Start Finch in your application supervision tree: + + children = [ + {Finch, name: MyApp.Finch} + ] + + Then create a client: + + client = Xdk.new(finch: MyApp.Finch, auth: {:bearer, token}) + + Or set a default via application config: + + config :xdk, :default_config, finch: MyApp.Finch + """ + end + + def require_finch!(%__MODULE__{finch: finch}), do: finch + + @doc false + def build_finch_request(%__MODULE__{} = client, method, path, opts) do + params = Keyword.get(opts, :params, %{}) + query = Keyword.get(opts, :query, []) + + url = client.base_url <> interpolate_path(path, params) + + url = + case query do + [] -> url + _ -> url <> "?" <> Xdk.Query.encode(query) + end + + headers = build_headers(client, opts) + + Finch.build(method, url, headers) end -{% raw %} defp interpolate_path(path, params) do Enum.reduce(params, path, fn {k, v}, acc -> String.replace(acc, "{#{k}}", URI.encode_www_form(to_string(v))) end) end - defp maybe_add_bearer(headers, nil), do: headers - defp maybe_add_bearer(headers, token), do: [{"authorization", "Bearer #{token}"} | headers] + defp resolve_auth(opts) do + case Keyword.get(opts, :auth) do + nil -> + case Keyword.get(opts, :bearer) do + nil -> nil + token -> {:bearer, token} + end + + auth -> + auth + end + end + + defp build_headers(%__MODULE__{} = client, opts) do + per_request_auth = Keyword.get(opts, :auth, client.auth) + + [{"user-agent", "xdk-elixir/#{@version}"}] + |> Kernel.++(client.headers) + |> Kernel.++(Keyword.get(opts, :headers, [])) + |> Kernel.++(auth_headers(per_request_auth)) + end + + defp auth_headers(nil), do: [] + defp auth_headers({:bearer, token}), do: [{"authorization", "Bearer #{token}"}] + defp auth_headers({:oauth2, %{access_token: token}}), do: [{"authorization", "Bearer #{token}"}] {% endraw %} + defp handle_response({:ok, %Finch.Response{status: 429, headers: headers, body: body}}) do + decoded = try_decode_body(body, headers) + {limit, remaining, reset, retry_after} = parse_rate_limit_headers(headers) + + {:error, + Xdk.Errors.RateLimitError.exception( + status: 429, + body: decoded, + headers: headers, + limit: limit, + remaining: remaining, + reset: reset, + retry_after_ms: retry_after + )} + end + defp handle_response({:ok, %Finch.Response{status: status, body: body, headers: headers}}) when status in 200..299 do - case decode_body(body, headers) do + case try_decode_body(body, headers) do {:ok, decoded} -> {:ok, decoded} - {:error, reason} -> {:error, Xdk.Errors.Unknown.UnknownError.exception(error: reason)} + {:error, err} -> {:error, Xdk.Errors.DecodeError.exception(error: err, raw_body: body)} end end defp handle_response({:ok, %Finch.Response{status: status, body: body, headers: headers}}) do decoded = - case decode_body(body, headers) do + case try_decode_body(body, headers) do {:ok, d} -> d _ -> body end - {:error, Xdk.Errors.ApiError.exception(status: status, body: decoded)} + {:error, Xdk.Errors.ApiError.exception(status: status, body: decoded, headers: headers)} end defp handle_response({:error, reason}) do {:error, Xdk.Errors.TransportError.exception(reason: reason)} end - defp decode_body("", _headers), do: {:ok, nil} - defp decode_body(nil, _headers), do: {:ok, nil} + defp try_decode_body("", _), do: {:ok, nil} + defp try_decode_body(nil, _), do: {:ok, nil} - defp decode_body(body, headers) do + defp try_decode_body(body, headers) do content_type = Enum.find_value(headers, "", fn - {"content-type", v} -> v + {k, v} when k in ["content-type", "Content-Type"] -> v _ -> nil end) @@ -121,4 +231,36 @@ defmodule Xdk do {:ok, body} end end + +{% raw %} + defp parse_rate_limit_headers(headers) do + get = fn name -> + Enum.find_value(headers, fn + {k, v} when k == name -> v + _ -> nil + end) + end + + limit = parse_int(get.("x-rate-limit-limit")) + remaining = parse_int(get.("x-rate-limit-remaining")) + reset = parse_int(get.("x-rate-limit-reset")) + + retry_after = + case reset do + nil -> nil + unix -> max(0, (unix - System.system_time(:second)) * 1000) + end + + {limit, remaining, reset, retry_after} + end +{% endraw %} + + defp parse_int(nil), do: nil + + defp parse_int(s) when is_binary(s) do + case Integer.parse(s) do + {n, _} -> n + :error -> nil + end + end end diff --git a/xdk-gen/templates/elixir/mix_exs.j2 b/xdk-gen/templates/elixir/mix_exs.j2 index 070d4ba7..b69ce621 100644 --- a/xdk-gen/templates/elixir/mix_exs.j2 +++ b/xdk-gen/templates/elixir/mix_exs.j2 @@ -13,8 +13,7 @@ defmodule Xdk.MixProject do def application do [ - extra_applications: [:logger], - mod: {Xdk.Application, []} + extra_applications: [:logger] ] end @@ -23,7 +22,7 @@ defmodule Xdk.MixProject do {:finch, "~> 0.19"}, {:jason, "~> 1.4"}, {:splode, "~> 0.2"}, - {:zoi, "~> 0.17"} + {:bypass, "~> 2.1", only: :test} ] end end diff --git a/xdk-gen/templates/elixir/paginator.j2 b/xdk-gen/templates/elixir/paginator.j2 new file mode 100644 index 00000000..f89a6165 --- /dev/null +++ b/xdk-gen/templates/elixir/paginator.j2 @@ -0,0 +1,63 @@ +{% raw %} +defmodule Xdk.Paginator do + @moduledoc """ + Cursor-based pagination helper. + + Wraps any paginated endpoint function into a lazy `Stream` that + automatically follows `next_token` / `pagination_token`. + + ## Example + + fetch = fn token -> + opts = [query: "elixir", max_results: 100] + opts = if token, do: Keyword.put(opts, :pagination_token, token), else: opts + Xdk.Posts.search_recent(client, opts) + end + + Xdk.Paginator.stream(fetch) + |> Stream.flat_map(fn {:ok, page} -> page["data"] || [] end) + |> Enum.take(500) + """ + + @type fetch_fun :: (String.t() | nil -> {:ok, map()} | {:error, term()}) + + @spec stream(fetch_fun(), keyword()) :: Enumerable.t() + def stream(fetch_fun, _opts \\ []) do + Stream.resource( + fn -> nil end, + fn + :halt -> + {:halt, :halt} + + token -> + case fetch_fun.(token) do + {:ok, page} -> + next = extract_next_token(page) + next_state = if next, do: next, else: :halt + {[{:ok, page}], next_state} + + {:error, _} = err -> + {[err], :halt} + end + end, + fn _ -> :ok end + ) + end + + @spec items(fetch_fun(), keyword()) :: Enumerable.t() + def items(fetch_fun, opts \\ []) do + data_key = Keyword.get(opts, :data_key, "data") + + stream(fetch_fun, opts) + |> Stream.flat_map(fn + {:ok, page} -> page[data_key] || [] + {:error, _} -> [] + end) + end + + defp extract_next_token(page) do + meta = page["meta"] || %{} + meta["next_token"] || meta["pagination_token"] + end +end +{% endraw %} diff --git a/xdk-gen/templates/elixir/query.j2 b/xdk-gen/templates/elixir/query.j2 new file mode 100644 index 00000000..3f1159da --- /dev/null +++ b/xdk-gen/templates/elixir/query.j2 @@ -0,0 +1,20 @@ +defmodule Xdk.Query do + @moduledoc false + + @spec build(list({String.t(), term()})) :: list({String.t(), String.t()}) + def build(pairs) do + pairs + |> Enum.reject(fn {_k, v} -> is_nil(v) end) + |> Enum.map(fn {k, v} -> {k, encode_value(v)} end) + end + + @spec encode(list({String.t(), term()})) :: String.t() + def encode(pairs) do + build(pairs) |> URI.encode_query() + end + + defp encode_value(v) when is_list(v), do: Enum.join(v, ",") + defp encode_value(true), do: "true" + defp encode_value(false), do: "false" + defp encode_value(v), do: to_string(v) +end diff --git a/xdk-gen/templates/elixir/streaming.j2 b/xdk-gen/templates/elixir/streaming.j2 new file mode 100644 index 00000000..3222d763 --- /dev/null +++ b/xdk-gen/templates/elixir/streaming.j2 @@ -0,0 +1,90 @@ +{% raw %} +defmodule Xdk.Streaming do + @moduledoc false + + @spec ndjson_stream(Xdk.t(), atom(), String.t(), keyword()) :: Enumerable.t() + def ndjson_stream(%Xdk{} = client, method, path, opts \\ []) do + finch = Xdk.require_finch!(client) + request = Xdk.build_finch_request(client, method, path, opts) + idle_timeout = Keyword.get(opts, :idle_timeout, 30_000) + + parent = self() + + Stream.resource( + fn -> + ref = make_ref() + + pid = + spawn_link(fn -> + result = + Finch.stream( + request, + finch, + _buffer = "", + fn + {:status, _status}, buf -> + buf + + {:headers, _headers}, buf -> + buf + + {:data, data}, buf -> + buf = buf <> data + {lines, rest} = split_lines(buf) + + for line <- lines do + case parse_line(line) do + :heartbeat -> :ok + {:ok, event} -> send(parent, {ref, {:event, event}}) + {:error, err} -> send(parent, {ref, {:decode_error, err, line}}) + end + end + + rest + end, + Keyword.get(opts, :request_opts, client.request_opts) + ) + + case result do + {:ok, _} -> send(parent, {ref, :done}) + {:error, reason} -> send(parent, {ref, {:stream_error, reason}}) + end + end) + + %{ref: ref, pid: pid} + end, + fn %{ref: ref} = state -> + receive do + {^ref, {:event, event}} -> {[{:ok, event}], state} + {^ref, {:decode_error, err, line}} -> {[{:error, {:decode, err, line}}], state} + {^ref, {:stream_error, reason}} -> {[{:error, {:stream, reason}}], state} + {^ref, :done} -> {:halt, state} + after + idle_timeout -> {[{:error, :idle_timeout}], %{state | ref: ref}} + end + end, + fn %{pid: pid} -> + if Process.alive?(pid), do: Process.exit(pid, :shutdown) + :ok + end + ) + end + + defp split_lines(buf) do + case String.split(buf, "\n") do + [single] -> {[], single} + parts -> {Enum.slice(parts, 0..-2//1), List.last(parts)} + end + end + + defp parse_line(line) do + line = String.trim(line) + + if line == "" do + :heartbeat + else + Jason.decode(line) + end + end +end +{% endraw %} diff --git a/xdk-gen/templates/elixir/test_helper.j2 b/xdk-gen/templates/elixir/test_helper.j2 index 869559e7..2649f806 100644 --- a/xdk-gen/templates/elixir/test_helper.j2 +++ b/xdk-gen/templates/elixir/test_helper.j2 @@ -1 +1,20 @@ +{% raw %} ExUnit.start() + +defmodule Xdk.TestHelper do + def setup_client(_context \\ %{}) do + bypass = Bypass.open() + finch_name = :"Xdk.TestFinch.#{System.unique_integer([:positive])}" + {:ok, _pid} = Finch.start_link(name: finch_name) + + client = + Xdk.new( + base_url: "http://localhost:#{bypass.port}", + finch: finch_name, + auth: {:bearer, "test-token"} + ) + + %{bypass: bypass, client: client} + end +end +{% endraw %} diff --git a/xdk-gen/templates/elixir/test_structure.j2 b/xdk-gen/templates/elixir/test_structure.j2 index eae32b2b..567708dd 100644 --- a/xdk-gen/templates/elixir/test_structure.j2 +++ b/xdk-gen/templates/elixir/test_structure.j2 @@ -1,5 +1,6 @@ defmodule Xdk.{{ tag.class_name }}Test do use ExUnit.Case, async: true + import Xdk.TestHelper describe "module structure" do test "module exists" do @@ -9,13 +10,70 @@ defmodule Xdk.{{ tag.class_name }}Test do {% for method in structural_test.methods %} {% set path_param_count = method.required_params | selectattr('location', 'equalto', 'path') | list | length %} {% set has_query_params = method.required_params | selectattr('location', 'equalto', 'query') | list | length > 0 or method.optional_params | default(value=[]) | length > 0 %} +{% set body_param_count = 1 if method.has_request_body else 0 %} test "{{ method.method_name }} function exists" do Code.ensure_loaded!(Xdk.{{ tag.class_name }}) - assert function_exported?(Xdk.{{ tag.class_name }}, :{{ method.method_name }}, {{ path_param_count + 1 }}){% if has_query_params %} or function_exported?(Xdk.{{ tag.class_name }}, :{{ method.method_name }}, {{ path_param_count + 2 }}){% endif %} + assert function_exported?(Xdk.{{ tag.class_name }}, :{{ method.method_name }}, {{ path_param_count + body_param_count + 1 }}){% if has_query_params %} or function_exported?(Xdk.{{ tag.class_name }}, :{{ method.method_name }}, {{ path_param_count + body_param_count + 2 }}){% endif %} end {% endfor %} {% endfor %} end +{% for contract in test_spec.contract_tests %} +{% if not contract.is_streaming %} +{% set path_params = contract.required_params | selectattr('location', 'equalto', 'path') | list %} +{% set query_params = contract.optional_params | default(value=[]) | list %} +{% set has_body = contract.request_body_schema is defined and contract.request_body_schema %} + + describe "{{ contract.method_name }}/{{ path_params | length + (1 if has_body else 0) + 1 }}{{ '+1' if query_params else '' }}" do + setup do + setup_client() + end + + test "sends request with auth header", %{bypass: bypass, client: client} do + Bypass.expect_once(bypass, "{{ contract.method | upper }}", "{{ contract.test_path }}", fn conn -> + assert Plug.Conn.get_req_header(conn, "authorization") == ["Bearer test-token"] + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> Plug.Conn.resp(200, ~s({"data":{}})) + end) + + {% if has_body and path_params %} + Xdk.{{ tag.class_name }}.{{ contract.method_name }}(client{% for param in path_params %}, "test_{{ param.variable_name }}"{% endfor %}, %{}) + {% elif has_body %} + Xdk.{{ tag.class_name }}.{{ contract.method_name }}(client, %{}) + {% elif path_params %} + Xdk.{{ tag.class_name }}.{{ contract.method_name }}(client{% for param in path_params %}, "test_{{ param.variable_name }}"{% endfor %}) + {% else %} + Xdk.{{ tag.class_name }}.{{ contract.method_name }}(client) + {% endif %} + end + + test "returns error for non-2xx", %{bypass: bypass, client: client} do + Bypass.expect_once(bypass, "{{ contract.method | upper }}", "{{ contract.test_path }}", fn conn -> + conn + |> Plug.Conn.put_resp_content_type("application/json") +{% raw %} + |> Plug.Conn.resp(404, ~s({"errors":[{"message":"Not Found"}]})) +{% endraw %} + end) + + {% if has_body and path_params %} + assert {:error, %Xdk.Errors.ApiError{status: 404}} = + Xdk.{{ tag.class_name }}.{{ contract.method_name }}(client{% for param in path_params %}, "test_{{ param.variable_name }}"{% endfor %}, %{}) + {% elif has_body %} + assert {:error, %Xdk.Errors.ApiError{status: 404}} = + Xdk.{{ tag.class_name }}.{{ contract.method_name }}(client, %{}) + {% elif path_params %} + assert {:error, %Xdk.Errors.ApiError{status: 404}} = + Xdk.{{ tag.class_name }}.{{ contract.method_name }}(client{% for param in path_params %}, "test_{{ param.variable_name }}"{% endfor %}) + {% else %} + assert {:error, %Xdk.Errors.ApiError{status: 404}} = + Xdk.{{ tag.class_name }}.{{ contract.method_name }}(client) + {% endif %} + end + end +{% endif %} +{% endfor %} end diff --git a/xdk-lib/src/testing.rs b/xdk-lib/src/testing.rs index 8fc38764..d0415d87 100644 --- a/xdk-lib/src/testing.rs +++ b/xdk-lib/src/testing.rs @@ -166,6 +166,8 @@ pub struct MethodSignature { pub return_type: String, /// Whether this method supports pagination pub supports_pagination: bool, + /// Whether this method has a request body + pub has_request_body: bool, } /// Parameter information for testing @@ -196,6 +198,8 @@ pub struct ContractTest { pub method: String, /// URL path template pub path: String, + /// URL path with path params replaced by test values (e.g., "/2/tweets/test_id") + pub test_path: String, /// Required parameters for this operation pub required_params: Vec, /// Optional parameters for this operation @@ -206,6 +210,8 @@ pub struct ContractTest { pub response_schema: ResponseSchema, /// Security requirements pub security_requirements: Vec, + /// Whether this operation uses streaming + pub is_streaming: bool, } /// Response schema information for contract testing @@ -338,6 +344,7 @@ fn generate_method_signature(operation: &OperationInfo) -> MethodSignature { optional_params, return_type: format!("{}Response", operation.class_name), supports_pagination: detect_pagination_support(operation), + has_request_body: operation.request_body.is_some(), } } @@ -375,16 +382,28 @@ fn extract_parameters(operation: &OperationInfo) -> (Vec, Vec ContractTest { let (required_params, optional_params) = extract_parameters(operation); + let mut test_path = operation.path.clone(); + for param in &required_params { + if param.location == "path" { + test_path = test_path.replace( + &format!("{{{}}}", param.name), + &format!("test_{}", param.variable_name), + ); + } + } + ContractTest { method_name: operation.method_name.clone(), class_name: operation.class_name.clone(), method: operation.method.clone(), path: operation.path.clone(), + test_path, required_params, optional_params, request_body_schema: generate_request_body_schema(operation), response_schema: generate_response_schema(operation), security_requirements: extract_security_requirements(operation), + is_streaming: operation.is_streaming, } } diff --git a/xdk-lib/tests/testing_tests.rs b/xdk-lib/tests/testing_tests.rs index 0e262ee8..47ad86e7 100644 --- a/xdk-lib/tests/testing_tests.rs +++ b/xdk-lib/tests/testing_tests.rs @@ -221,6 +221,7 @@ fn test_method_signature_structure() { optional_params: vec![], return_type: "GetUserResponse".to_string(), supports_pagination: false, + has_request_body: false, }; assert_eq!(method_sig.method_name, "get_user"); @@ -245,6 +246,8 @@ fn test_contract_test_structure() { expected_fields: vec![], }, security_requirements: vec![], + is_streaming: false, + test_path: "/users/test_id".to_string(), }; assert_eq!(contract_test.method_name, "get_user"); From e7a23b590f0b96e586499b7b4517f1c161c6d6e5 Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:50:48 -0600 Subject: [PATCH 3/6] feat(elixir): exclude integration tests by default in test_helper template Amp-Thread-ID: https://ampcode.com/threads/T-019c38ef-224f-775a-b955-714dfa4e5243 Co-authored-by: Amp --- xdk-gen/templates/elixir/test_helper.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/xdk-gen/templates/elixir/test_helper.j2 b/xdk-gen/templates/elixir/test_helper.j2 index 2649f806..3e8439d5 100644 --- a/xdk-gen/templates/elixir/test_helper.j2 +++ b/xdk-gen/templates/elixir/test_helper.j2 @@ -1,5 +1,5 @@ {% raw %} -ExUnit.start() +ExUnit.start(exclude: [:integration]) defmodule Xdk.TestHelper do def setup_client(_context \\ %{}) do From 876a25049751fd46bc284ca5313b2ab3b68a67b7 Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:58:21 -0600 Subject: [PATCH 4/6] feat(elixir): add OAuth 1.0a support via oauther - Add {:oauth1, credentials} auth type to client - Sign requests with HMAC-SHA1 inside request/4 where method+URL are known - Add oauther ~> 1.3 as dependency - Add .env to gitignore template Amp-Thread-ID: https://ampcode.com/threads/T-019c38ef-224f-775a-b955-714dfa4e5243 Co-authored-by: Amp --- xdk-gen/templates/elixir/gitignore.j2 | 1 + xdk-gen/templates/elixir/main_client.j2 | 33 +++++++++++++++++++------ xdk-gen/templates/elixir/mix_exs.j2 | 1 + 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/xdk-gen/templates/elixir/gitignore.j2 b/xdk-gen/templates/elixir/gitignore.j2 index 57708e02..3e7c0a11 100644 --- a/xdk-gen/templates/elixir/gitignore.j2 +++ b/xdk-gen/templates/elixir/gitignore.j2 @@ -6,3 +6,4 @@ erl_crash.dump *.ez *.beam /tmp/ +.env diff --git a/xdk-gen/templates/elixir/main_client.j2 b/xdk-gen/templates/elixir/main_client.j2 index 458c2b7f..a1dc2609 100644 --- a/xdk-gen/templates/elixir/main_client.j2 +++ b/xdk-gen/templates/elixir/main_client.j2 @@ -15,7 +15,7 @@ defmodule Xdk do :request_opts ] - @type auth :: nil | {:bearer, String.t()} | {:oauth2, map()} + @type auth :: nil | {:bearer, String.t()} | {:oauth2, map()} | {:oauth1, OAuther.Credentials.t()} @type t :: %__MODULE__{ base_url: String.t(), @@ -75,7 +75,7 @@ defmodule Xdk do _ -> url <> "?" <> Xdk.Query.encode(query) end - headers = build_headers(client, opts) + headers = build_headers(client, method, url, opts) {encoded_body, headers} = cond do @@ -139,7 +139,7 @@ defmodule Xdk do _ -> url <> "?" <> Xdk.Query.encode(query) end - headers = build_headers(client, opts) + headers = build_headers(client, method, url, opts) Finch.build(method, url, headers) end @@ -163,18 +163,35 @@ defmodule Xdk do end end - defp build_headers(%__MODULE__{} = client, opts) do + defp build_headers(%__MODULE__{} = client, method, url, opts) do per_request_auth = Keyword.get(opts, :auth, client.auth) [{"user-agent", "xdk-elixir/#{@version}"}] |> Kernel.++(client.headers) |> Kernel.++(Keyword.get(opts, :headers, [])) - |> Kernel.++(auth_headers(per_request_auth)) + |> Kernel.++(auth_headers(per_request_auth, method, url)) end - defp auth_headers(nil), do: [] - defp auth_headers({:bearer, token}), do: [{"authorization", "Bearer #{token}"}] - defp auth_headers({:oauth2, %{access_token: token}}), do: [{"authorization", "Bearer #{token}"}] + defp auth_headers(nil, _method, _url), do: [] + defp auth_headers({:bearer, token}, _method, _url), do: [{"authorization", "Bearer #{token}"}] + defp auth_headers({:oauth2, %{access_token: token}}, _method, _url), do: [{"authorization", "Bearer #{token}"}] + + defp auth_headers({:oauth1, credentials}, method, url) do + {uri, query_string} = + case String.split(url, "?", parts: 2) do + [base] -> {base, ""} + [base, qs] -> {base, qs} + end + + query_params = + query_string + |> URI.decode_query() + |> Enum.to_list() + + signed_params = OAuther.sign(to_string(method), uri, query_params, credentials) + {header, _params} = OAuther.header(signed_params) + [header] + end {% endraw %} defp handle_response({:ok, %Finch.Response{status: 429, headers: headers, body: body}}) do diff --git a/xdk-gen/templates/elixir/mix_exs.j2 b/xdk-gen/templates/elixir/mix_exs.j2 index b69ce621..2b2c2d2a 100644 --- a/xdk-gen/templates/elixir/mix_exs.j2 +++ b/xdk-gen/templates/elixir/mix_exs.j2 @@ -22,6 +22,7 @@ defmodule Xdk.MixProject do {:finch, "~> 0.19"}, {:jason, "~> 1.4"}, {:splode, "~> 0.2"}, + {:oauther, "~> 1.3"}, {:bypass, "~> 2.1", only: :test} ] end From 5ceeb9386ee897328f8490ab9e64ba4e7217dda3 Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Sat, 7 Feb 2026 11:09:03 -0600 Subject: [PATCH 5/6] feat(elixir): update templates for v1.0.0 Hex publishing - mix_exs.j2: add Hex metadata, dialyzer, credo, ex_doc, quality alias, rename to xdk_elixir - errors.j2: add @moduledoc to all modules - gitignore.j2: add PLT, coverage, editor entries - Bump elixir version to 1.0.0 in xdk-config.toml Amp-Thread-ID: https://ampcode.com/threads/T-019c390a-abe3-726d-a4a1-9cfc2522667b Co-authored-by: Amp --- xdk-config.toml | 2 +- xdk-gen/templates/elixir/errors.j2 | 10 ++++ xdk-gen/templates/elixir/gitignore.j2 | 21 ++++++++ xdk-gen/templates/elixir/mix_exs.j2 | 72 +++++++++++++++++++++++++-- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/xdk-config.toml b/xdk-config.toml index 5ac63f12..2909928b 100644 --- a/xdk-config.toml +++ b/xdk-config.toml @@ -9,4 +9,4 @@ python = "0.5.0" typescript = "0.4.0" # Elixir SDK version -elixir = "0.1.0" +elixir = "1.0.0" diff --git a/xdk-gen/templates/elixir/errors.j2 b/xdk-gen/templates/elixir/errors.j2 index c383300e..737cfcc4 100644 --- a/xdk-gen/templates/elixir/errors.j2 +++ b/xdk-gen/templates/elixir/errors.j2 @@ -1,4 +1,6 @@ defmodule Xdk.Errors do + @moduledoc "Structured error types for the XDK client." + use Splode, error_classes: [ api: Xdk.Errors.Api, @@ -16,19 +18,23 @@ defmodule Xdk.Errors do end defmodule Xdk.Errors.Api do + @moduledoc false use Splode.ErrorClass, class: :api end defmodule Xdk.Errors.Transport do + @moduledoc false use Splode.ErrorClass, class: :transport end defmodule Xdk.Errors.Decode do + @moduledoc false use Splode.ErrorClass, class: :decode end {% raw %} defmodule Xdk.Errors.ApiError do + @moduledoc "Raised when the X API returns a non-2xx HTTP status." use Splode.Error, fields: [:status, :body, :headers], class: :api def message(%{status: status, body: body}) do @@ -37,6 +43,7 @@ defmodule Xdk.Errors.ApiError do end defmodule Xdk.Errors.RateLimitError do + @moduledoc "Raised when the X API returns HTTP 429 (rate limit exceeded)." use Splode.Error, fields: [:status, :body, :headers, :limit, :remaining, :reset, :retry_after_ms], class: :api @@ -47,6 +54,7 @@ defmodule Xdk.Errors.RateLimitError do end defmodule Xdk.Errors.TransportError do + @moduledoc "Raised when the HTTP transport (Finch) fails." use Splode.Error, fields: [:reason], class: :transport def message(%{reason: reason}) do @@ -55,6 +63,7 @@ defmodule Xdk.Errors.TransportError do end defmodule Xdk.Errors.DecodeError do + @moduledoc "Raised when JSON decoding of an API response fails." use Splode.Error, fields: [:error, :raw_body], class: :decode def message(%{error: error}) do @@ -63,6 +72,7 @@ defmodule Xdk.Errors.DecodeError do end defmodule Xdk.Errors.UnknownError do + @moduledoc false use Splode.Error, fields: [:error], class: :decode def message(%{error: error}) do diff --git a/xdk-gen/templates/elixir/gitignore.j2 b/xdk-gen/templates/elixir/gitignore.j2 index 3e7c0a11..827b54b4 100644 --- a/xdk-gen/templates/elixir/gitignore.j2 +++ b/xdk-gen/templates/elixir/gitignore.j2 @@ -1,3 +1,4 @@ +# Build artifacts /_build/ /deps/ /doc/ @@ -6,4 +7,24 @@ erl_crash.dump *.ez *.beam /tmp/ + +# Dialyzer PLT files +/priv/plts/ +*.plt +*.plt.hash + +# Coverage +/cover/ + +# Editor/IDE +.elixir_ls/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment .env diff --git a/xdk-gen/templates/elixir/mix_exs.j2 b/xdk-gen/templates/elixir/mix_exs.j2 index 2b2c2d2a..9abe66d8 100644 --- a/xdk-gen/templates/elixir/mix_exs.j2 +++ b/xdk-gen/templates/elixir/mix_exs.j2 @@ -1,13 +1,30 @@ +# AUTO-GENERATED FILE - DO NOT EDIT +# This file was automatically generated by the XDK build tool. +# Any manual changes will be overwritten on the next generation. defmodule Xdk.MixProject do use Mix.Project + @version "{{ version }}" + @source_url "https://github.com/mikehostetler/xdk-elixir" + @description "Auto-generated Elixir client for the X (Twitter) API v2" + def project do [ - app: :xdk, - version: "{{ version }}", + app: :xdk_elixir, + version: @version, elixir: "~> 1.15", start_permanent: Mix.env() == :prod, - deps: deps() + deps: deps(), + aliases: aliases(), + dialyzer: dialyzer(), + + # Hex + name: "XDK", + description: @description, + source_url: @source_url, + homepage_url: @source_url, + package: package(), + docs: docs() ] end @@ -23,7 +40,54 @@ defmodule Xdk.MixProject do {:jason, "~> 1.4"}, {:splode, "~> 0.2"}, {:oauther, "~> 1.3"}, - {:bypass, "~> 2.1", only: :test} + + # Dev/Test + {:bypass, "~> 2.1", only: :test}, + {:dotenvy, "~> 0.8", only: [:dev, :test]}, + {:ex_doc, "~> 0.31", only: :dev, runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} + ] + end + + defp aliases do + [ + quality: [ + "compile --warnings-as-errors", + "format --check-formatted", + "credo --strict", + "dialyzer" + ] + ] + end + + defp dialyzer do + [ + plt_file: {:no_warn, "priv/plts/dialyzer.plt"}, + plt_add_apps: [:mix], + ignore_warnings: ".dialyzer_ignore.exs" + ] + end + + defp package do + [ + name: "xdk_elixir", + licenses: ["Apache-2.0"], + links: %{ + "GitHub" => @source_url, + "Changelog" => "#{@source_url}/blob/main/CHANGELOG.md", + "Generator" => "https://github.com/mikehostetler/xdk" + }, + files: ~w(lib .formatter.exs mix.exs README.md LICENSE CHANGELOG.md) + ] + end + + defp docs do + [ + main: "readme", + extras: ["README.md", "CHANGELOG.md", "LICENSE"], + source_ref: "v#{@version}", + source_url: @source_url ] end end From 4acffe8ad6de936694513ea8a0c960ecfd0ba206 Mon Sep 17 00:00:00 2001 From: Mike Hostetler <84222+mikehostetler@users.noreply.github.com> Date: Sat, 7 Feb 2026 11:14:56 -0600 Subject: [PATCH 6/6] fix(elixir): resolve mix docs warnings in templates - streaming.j2: add @moduledoc (was hidden) - errors.j2: use Exception.t() instead of Splode macro types, drop UnknownError from typedoc Amp-Thread-ID: https://ampcode.com/threads/T-019c390a-abe3-726d-a4a1-9cfc2522667b Co-authored-by: Amp --- xdk-gen/templates/elixir/errors.j2 | 13 +++++++------ xdk-gen/templates/elixir/streaming.j2 | 7 ++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/xdk-gen/templates/elixir/errors.j2 b/xdk-gen/templates/elixir/errors.j2 index 737cfcc4..44692928 100644 --- a/xdk-gen/templates/elixir/errors.j2 +++ b/xdk-gen/templates/elixir/errors.j2 @@ -9,12 +9,13 @@ defmodule Xdk.Errors do ], unknown_error: Xdk.Errors.UnknownError - @type error :: - Xdk.Errors.ApiError.t() - | Xdk.Errors.RateLimitError.t() - | Xdk.Errors.TransportError.t() - | Xdk.Errors.DecodeError.t() - | Xdk.Errors.UnknownError.t() + @typedoc """ + Any error returned by the XDK client. + + One of `Xdk.Errors.ApiError`, `Xdk.Errors.RateLimitError`, + `Xdk.Errors.TransportError`, or `Xdk.Errors.DecodeError`. + """ + @type error :: Exception.t() end defmodule Xdk.Errors.Api do diff --git a/xdk-gen/templates/elixir/streaming.j2 b/xdk-gen/templates/elixir/streaming.j2 index 3222d763..bb25896b 100644 --- a/xdk-gen/templates/elixir/streaming.j2 +++ b/xdk-gen/templates/elixir/streaming.j2 @@ -1,6 +1,11 @@ {% raw %} defmodule Xdk.Streaming do - @moduledoc false + @moduledoc """ + Low-level NDJSON streaming helper for the X API. + + Used internally by `Xdk.Stream` to consume newline-delimited JSON + streams (filtered stream, sample stream, etc.). + """ @spec ndjson_stream(Xdk.t(), atom(), String.t(), keyword()) :: Enumerable.t() def ndjson_stream(%Xdk{} = client, method, path, opts \\ []) do