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/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..2909928b 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 = "1.0.0" diff --git a/xdk-gen/src/elixir/generator.rs b/xdk-gen/src/elixir/generator.rs new file mode 100644 index 00000000..fdff9486 --- /dev/null +++ b/xdk-gen/src/elixir/generator.rs @@ -0,0 +1,66 @@ +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()", + _ => "String.t()", + } + .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 "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", + 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..c04cc79b --- /dev/null +++ b/xdk-gen/src/elixir/mod.rs @@ -0,0 +1,73 @@ +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/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/client_class.j2 b/xdk-gen/templates/elixir/client_class.j2 new file mode 100644 index 00000000..dd9117f9 --- /dev/null +++ b/xdk-gen/templates/elixir/client_class.j2 @@ -0,0 +1,71 @@ +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="") }} + + {{ operation.method | upper }} {{ operation.path }} + {% if operation.description %} + {{ operation.description }} + {% endif %} + """ + @spec {{ operation.method_name }}(Xdk.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 path_params %}, {{ param.variable_name }}{% endfor %} + {%- if has_body %}, body{% endif %} + {%- if query_params %}, opts \\ []{% endif %} + ) do + {% if query_params %} + query = + [ + {%- for param in query_params %} + {"{{ param.original_name }}", Keyword.get(opts, :{{ param.variable_name }})}{% if not loop.last %},{% endif %} + {%- endfor %} + ] + |> Xdk.Query.build() + {% endif %} + + {% 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 %} + "{{ param.original_name }}" => {{ param.variable_name }}{% if not loop.last %},{% endif %} + {%- endfor %} + }{% if query_params %},{% endif %} + {% endif %} + {% if query_params %} + 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 new file mode 100644 index 00000000..44692928 --- /dev/null +++ b/xdk-gen/templates/elixir/errors.j2 @@ -0,0 +1,83 @@ +defmodule Xdk.Errors do + @moduledoc "Structured error types for the XDK client." + + use Splode, + error_classes: [ + api: Xdk.Errors.Api, + transport: Xdk.Errors.Transport, + decode: Xdk.Errors.Decode + ], + unknown_error: Xdk.Errors.UnknownError + + @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 + @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 + "X API error (HTTP #{status}): #{inspect(body)}" + end +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 + + 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 + @moduledoc "Raised when the HTTP transport (Finch) fails." + use Splode.Error, fields: [:reason], class: :transport + + def message(%{reason: reason}) do + "Transport error: #{inspect(reason)}" + end +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 + "JSON decode error: #{inspect(error)}" + end +end + +defmodule Xdk.Errors.UnknownError do + @moduledoc false + 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/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..827b54b4 --- /dev/null +++ b/xdk-gen/templates/elixir/gitignore.j2 @@ -0,0 +1,30 @@ +# Build artifacts +/_build/ +/deps/ +/doc/ +/.fetch +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/main_client.j2 b/xdk-gen/templates/elixir/main_client.j2 new file mode 100644 index 00000000..a1dc2609 --- /dev/null +++ b/xdk-gen/templates/elixir/main_client.j2 @@ -0,0 +1,283 @@ +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, + :auth, + :headers, + :request_opts + ] + + @type auth :: nil | {:bearer, String.t()} | {:oauth2, map()} | {:oauth1, OAuther.Credentials.t()} + + @type t :: %__MODULE__{ + base_url: 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), + 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, Xdk.Errors.error()} + def request(%__MODULE__{} = client, method, path, opts \\ []) do + params = Keyword.get(opts, :params, %{}) + query = Keyword.get(opts, :query, []) + 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 <> "?" <> Xdk.Query.encode(query) + end + + headers = build_headers(client, method, url, opts) + + {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} + + true -> + {nil, headers} + end + + finch = require_finch!(client) + + method + |> Finch.build(url, headers, encoded_body) + |> 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, method, url, opts) + + Finch.build(method, url, headers) + end + + 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 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, 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, method, url)) + end + + 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 + 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 try_decode_body(body, headers) do + {:ok, decoded} -> {:ok, decoded} + {: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 try_decode_body(body, headers) do + {:ok, d} -> d + _ -> body + end + + {: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 try_decode_body("", _), do: {:ok, nil} + defp try_decode_body(nil, _), do: {:ok, nil} + + defp try_decode_body(body, headers) do + content_type = + Enum.find_value(headers, "", fn + {k, v} when k in ["content-type", "Content-Type"] -> v + _ -> nil + end) + + if String.contains?(content_type, "application/json") do + Jason.decode(body) + else + {: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 new file mode 100644 index 00000000..9abe66d8 --- /dev/null +++ b/xdk-gen/templates/elixir/mix_exs.j2 @@ -0,0 +1,93 @@ +# 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_elixir, + version: @version, + elixir: "~> 1.15", + start_permanent: Mix.env() == :prod, + deps: deps(), + aliases: aliases(), + dialyzer: dialyzer(), + + # Hex + name: "XDK", + description: @description, + source_url: @source_url, + homepage_url: @source_url, + package: package(), + docs: docs() + ] + end + + def application do + [ + extra_applications: [:logger] + ] + end + + defp deps do + [ + {:finch, "~> 0.19"}, + {:jason, "~> 1.4"}, + {:splode, "~> 0.2"}, + {:oauther, "~> 1.3"}, + + # 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 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/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/streaming.j2 b/xdk-gen/templates/elixir/streaming.j2 new file mode 100644 index 00000000..bb25896b --- /dev/null +++ b/xdk-gen/templates/elixir/streaming.j2 @@ -0,0 +1,95 @@ +{% raw %} +defmodule Xdk.Streaming do + @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 + 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 new file mode 100644 index 00000000..3e8439d5 --- /dev/null +++ b/xdk-gen/templates/elixir/test_helper.j2 @@ -0,0 +1,20 @@ +{% raw %} +ExUnit.start(exclude: [:integration]) + +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 new file mode 100644 index 00000000..567708dd --- /dev/null +++ b/xdk-gen/templates/elixir/test_structure.j2 @@ -0,0 +1,79 @@ +defmodule Xdk.{{ tag.class_name }}Test do + use ExUnit.Case, async: true + import Xdk.TestHelper + + 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 %} +{% 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 + 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/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" { ("", "//", "") 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");