#!/usr/bin/env elixir # generate.exs — Auto-generates the ElevenLabs Elixir SDK from the OpenAPI spec. # # Usage: # mix run generate.exs # # This script: # 1. Fetches the ElevenLabs OpenAPI JSON spec # 2. Parses all endpoints and generates Elixir wrapper functions with NimbleOptions validation # 3. Writes lib/eleven_labs.ex # 4. Bumps the patch version in mix.exs if the generated code changed defmodule ElevenLabs.Generator do @openapi_url "https://api.elevenlabs.io/openapi.json" @output_file "lib/eleven_labs.ex" @mix_file "mix.exs" def run do IO.puts("Fetching OpenAPI spec...") {:ok, spec} = fetch_spec() IO.puts("Generating client code...") code = generate_module(spec) old_code = if File.exists?(@output_file) do File.read!(@output_file) else "" end if code != old_code do File.write!(@output_file, code) IO.puts("Wrote #{@output_file}") bump_version() else IO.puts("No changes detected — skipping write and version bump.") end end defp fetch_spec do Application.ensure_all_started(:req) case Req.get(@openapi_url) do {:ok, %Req.Response{status: 200, body: body}} when is_map(body) -> {:ok, body} {:ok, %Req.Response{status: 200, body: body}} when is_binary(body) -> {:ok, Jason.decode!(body)} {:ok, %Req.Response{status: status}} -> {:error, "HTTP #{status}"} {:error, reason} -> {:error, reason} end end # --------------------------------------------------------------------------- # Module template # --------------------------------------------------------------------------- defp generate_module(spec) do base_url = "https://api.elevenlabs.io" api_version = get_in(spec, ["info", "version"]) || "1.0" paths = Map.get(spec, "paths", %{}) functions = paths |> Enum.flat_map(fn {path, methods} -> path_level_params = Map.get(methods, "parameters", []) methods |> Enum.reject(fn {key, _} -> key == "parameters" end) |> Enum.map(fn {method, operation} -> {method, path, operation, path_level_params} end) end) |> Enum.sort_by(fn {_method, _path, op, _} -> Map.get(op, "operationId", "") end) |> Enum.map(&generate_function(&1, spec)) |> Enum.join("\n") bt = <<96>> auth_header_line = ~S' headers: [{"xi-api-key", api_key}]' """ # This file is auto-generated by generate.exs — DO NOT EDIT MANUALLY. # Re-generate with: mix run generate.exs defmodule ElevenLabs do @moduledoc \"\"\" Auto-generated ElevenLabs API #{api_version} client. Generated from the OpenAPI spec at: #{@openapi_url} ## Configuration Set your API key in application config: config :eleven_labs, api_key: "your-xi-api-key" Or pass it as an option to any function: ElevenLabs.text_to_speech_full("voice_id", [text: "Hello"], api_key: "your-xi-api-key") ## Options All functions accept an optional keyword list as the last argument with: * #{bt}:api_key#{bt} - Override the API key for this request * #{bt}:base_url#{bt} - Override the default API base URL * Any other options are passed through to the #{bt}Req#{bt} request ## Response Types Most endpoints return JSON maps. However, several endpoints (text-to-speech, speech-to-speech, sound generation, audio isolation, etc.) return raw binary audio data (MP3, WAV, PCM bytes). #{bt}Req#{bt} handles this natively — the response body will be a binary for audio endpoints and a decoded map for JSON endpoints. ## Usage {:ok, response} = ElevenLabs.get_voices() {:ok, response} = ElevenLabs.text_to_speech_full( "21m00Tcm4TlvDq8ikWAM", text: "Hello, world!" ) # response.body is raw MP3 binary audio data \"\"\" @type response :: {:ok, Req.Response.t()} | {:error, Exception.t()} @base_url "#{base_url}" defp client(opts) do api_key = Keyword.get_lazy(opts, :api_key, fn -> Application.get_env(:eleven_labs, :api_key) || raise \"\"\" ElevenLabs API key not found. Set it in your config: config :eleven_labs, api_key: "your-xi-api-key" Or pass it as an option: ElevenLabs.get_voices(api_key: "your-xi-api-key") \"\"\" end) {base_url, opts} = Keyword.pop(opts, :base_url, @base_url) opts = Keyword.delete(opts, :api_key) Req.new( base_url: base_url, #{auth_header_line} ) |> Req.merge(opts) end defp to_body(validated_params, key_mapping) do Map.new(validated_params, fn {k, v} -> json_key = Map.fetch!(key_mapping, k) {json_key, to_json_value(v)} end) end defp to_query(validated_params, key_mapping) do Enum.map(validated_params, fn {k, v} -> {Map.fetch!(key_mapping, k), to_json_value(v)} end) end defp to_multipart(validated_params, key_mapping) do Enum.map(validated_params, fn {k, v} -> json_key = Map.fetch!(key_mapping, k) {json_key, to_json_value(v)} end) end defp to_json_value(atom) when is_atom(atom) and atom not in [true, false, nil] do Atom.to_string(atom) end defp to_json_value(map) when is_map(map), do: map defp to_json_value(list) when is_list(list) do if Keyword.keyword?(list) and list != [] do Map.new(list, fn {k, v} -> {camelize(Atom.to_string(k)), to_json_value(v)} end) else Enum.map(list, &to_json_value/1) end end defp to_json_value(value), do: value defp camelize(string) do [first | rest] = String.split(string, "_") Enum.join([first | Enum.map(rest, &String.capitalize/1)]) end #{functions}end """ end # --------------------------------------------------------------------------- # Per-function generation # --------------------------------------------------------------------------- defp generate_function({method, path, operation, path_level_params}, spec) do operation_id = Map.get(operation, "operationId", "unknown") func_name = to_snake_case(operation_id) summary = Map.get(operation, "summary", "") tag = operation |> Map.get("tags", []) |> List.first() || "" op_params = Map.get(operation, "parameters", []) all_params = path_level_params ++ op_params # Filter out the xi-api-key header param — the client handles auth automatically all_params = Enum.reject(all_params, fn p -> Map.get(p, "name") == "xi-api-key" and Map.get(p, "in") == "header" end) path_params = all_params |> Enum.filter(fn p -> Map.get(p, "in") == "path" end) |> Enum.map(fn p -> Map.get(p, "name") end) query_params = all_params |> Enum.filter(fn p -> Map.get(p, "in") == "query" end) has_body = Map.has_key?(operation, "requestBody") is_multipart = is_multipart_request?(operation) http_method = String.upcase(method) # Extract request body schema body_properties = if has_body, do: extract_body_properties(operation, spec, is_multipart), else: [] required_keys = if has_body, do: extract_required_keys(operation, spec, is_multipart), else: [] # Build schema/mapping for body params body_schema_code = if has_body, do: generate_schema(func_name, body_properties, required_keys), else: nil body_key_mapping_code = if has_body, do: generate_key_mapping(func_name, body_properties), else: nil # Build schema/mapping for query params (skip if endpoint has a body — query params # can be passed via opts and we avoid generating unused module attributes) has_query_schema = query_params != [] and not has_body query_schema_code = if has_query_schema, do: generate_query_schema(func_name, query_params), else: nil query_key_mapping_code = if has_query_schema, do: generate_query_key_mapping(func_name, query_params), else: nil query_param_names = Enum.map(query_params, fn p -> Map.get(p, "name") end) # Check if response is binary (audio/video) is_binary_response = is_binary_response?(operation) # Build docs doc = build_doc( summary, http_method, path, tag, path_params, query_param_names, has_body, func_name, has_query_schema, is_binary_response, is_multipart ) # Build non-bang and bang function bodies {sig, body} = build_function_body( func_name, method, path, path_params, query_param_names, has_body, has_query_schema, is_multipart, false ) {bang_sig, bang_body} = build_function_body( func_name, method, path, path_params, query_param_names, has_body, has_query_schema, is_multipart, true ) # Build typespecs spec_code = build_typespec(func_name, path_params, has_body || has_query_schema, false) bang_spec_code = build_typespec(func_name, path_params, has_body || has_query_schema, true) parts = [ body_schema_code, body_key_mapping_code, query_schema_code, query_key_mapping_code, doc, spec_code, " #{sig}", body, "", doc_bang(func_name), bang_spec_code, " #{bang_sig}", bang_body, "" ] parts |> Enum.reject(&is_nil/1) |> Enum.join("\n") end # --------------------------------------------------------------------------- # Request/Response Type Detection # --------------------------------------------------------------------------- defp is_multipart_request?(operation) do case get_in(operation, ["requestBody", "content"]) do %{"multipart/form-data" => _} -> true _ -> false end end defp is_binary_response?(operation) do case get_in(operation, ["responses", "200", "content"]) do nil -> false content when is_map(content) -> content |> Map.keys() |> Enum.any?(fn ct -> String.starts_with?(ct, "audio/") or String.starts_with?(ct, "video/") or ct == "application/zip" or ct == "application/x-zip" end) end end # --------------------------------------------------------------------------- # Schema Extraction # --------------------------------------------------------------------------- defp extract_body_properties(operation, spec, true = _is_multipart) do schema = get_in(operation, ["requestBody", "content", "multipart/form-data", "schema"]) || %{} resolve_properties(schema, spec) end defp extract_body_properties(operation, spec, false = _is_multipart) do schema = get_in(operation, ["requestBody", "content", "application/json", "schema"]) || %{} resolve_properties(schema, spec) end defp extract_required_keys(operation, spec, true = _is_multipart) do schema = get_in(operation, ["requestBody", "content", "multipart/form-data", "schema"]) || %{} resolve_required(schema, spec) end defp extract_required_keys(operation, spec, false = _is_multipart) do schema = get_in(operation, ["requestBody", "content", "application/json", "schema"]) || %{} resolve_required(schema, spec) end defp resolve_properties(schema, spec) do cond do Map.has_key?(schema, "$ref") -> resolved = resolve_ref(schema["$ref"], spec) resolve_properties(resolved, spec) Map.has_key?(schema, "allOf") -> schema["allOf"] |> Enum.flat_map(fn sub -> resolve_properties(sub, spec) end) |> Enum.uniq_by(fn {name, _} -> name end) Map.has_key?(schema, "properties") -> schema["properties"] |> Enum.map(fn {name, prop_schema} -> prop_schema = resolve_if_ref(prop_schema, spec) {name, prop_schema} end) true -> [] end end defp resolve_required(schema, spec) do cond do Map.has_key?(schema, "$ref") -> resolved = resolve_ref(schema["$ref"], spec) resolve_required(resolved, spec) Map.has_key?(schema, "allOf") -> schema["allOf"] |> Enum.flat_map(fn sub -> resolve_required(sub, spec) end) |> Enum.uniq() Map.has_key?(schema, "required") -> schema["required"] true -> [] end end defp resolve_ref(ref, spec) do path = ref |> String.trim_leading("#/") |> String.split("/") get_in(spec, path) || %{} end defp resolve_if_ref(%{"$ref" => ref}, spec), do: resolve_ref(ref, spec) defp resolve_if_ref(schema, _spec), do: schema # --------------------------------------------------------------------------- # NimbleOptions Schema Generation (body params) # --------------------------------------------------------------------------- defp generate_schema(func_name, properties, required_keys) do opts = properties |> Enum.map(fn {name, prop_schema} -> snake = to_snake_case(name) type = openapi_to_nimble_type(prop_schema) required = name in required_keys doc = Map.get(prop_schema, "description", "") parts = ["type: #{type}"] parts = if required, do: parts ++ ["required: true"], else: parts parts = if doc != "", do: parts ++ ["doc: #{inspect(doc)}"], else: parts " #{snake}: [#{Enum.join(parts, ", ")}]" end) |> Enum.join(",\n") " @#{func_name}_schema NimbleOptions.new!([\n#{opts}\n ])\n" end defp generate_key_mapping(func_name, properties) do mappings = properties |> Enum.map(fn {name, _} -> snake = to_snake_case(name) "#{snake}: \"#{name}\"" end) |> Enum.join(", ") " @#{func_name}_key_mapping %{#{mappings}}\n" end # --------------------------------------------------------------------------- # NimbleOptions Schema Generation (query params) # --------------------------------------------------------------------------- defp generate_query_schema(func_name, query_params) do opts = query_params |> Enum.map(fn param -> name = Map.get(param, "name") snake = to_snake_case(name) param_schema = Map.get(param, "schema", %{}) type = openapi_to_nimble_type(param_schema) required = Map.get(param, "required", false) doc = Map.get(param, "description", "") parts = ["type: #{type}"] parts = if required, do: parts ++ ["required: true"], else: parts parts = if doc != "", do: parts ++ ["doc: #{inspect(doc)}"], else: parts " #{snake}: [#{Enum.join(parts, ", ")}]" end) |> Enum.join(",\n") " @#{func_name}_query_schema NimbleOptions.new!([\n#{opts}\n ])\n" end defp generate_query_key_mapping(func_name, query_params) do mappings = query_params |> Enum.map(fn param -> name = Map.get(param, "name") snake = to_snake_case(name) "#{snake}: \"#{name}\"" end) |> Enum.join(", ") " @#{func_name}_query_key_mapping %{#{mappings}}\n" end # --------------------------------------------------------------------------- # OpenAPI → NimbleOptions type mapping # --------------------------------------------------------------------------- defp openapi_to_nimble_type(%{"type" => "string", "enum" => values}) when is_list(values) do inspected = values |> Enum.map(&atom_literal/1) |> Enum.join(", ") "{:in, [#{inspected}]}" end defp openapi_to_nimble_type(%{"type" => "string", "format" => "binary"}), do: ":any" defp openapi_to_nimble_type(%{"type" => "string"}), do: ":string" defp openapi_to_nimble_type(%{"type" => "integer"}), do: ":integer" defp openapi_to_nimble_type(%{"type" => "number"}), do: "{:or, [:integer, :float]}" defp openapi_to_nimble_type(%{"type" => "boolean"}), do: ":boolean" defp openapi_to_nimble_type(%{"type" => "array", "items" => %{"type" => "string"}}) do "{:list, :string}" end defp openapi_to_nimble_type(%{"type" => "array"}) do "{:list, :any}" end # Object with no properties defined → arbitrary data (JSON Schema, headers, etc.) defp openapi_to_nimble_type(%{"type" => "object", "properties" => _}), do: ":keyword_list" defp openapi_to_nimble_type(%{"type" => "object"}), do: ":any" # Ref-resolved objects with known properties defp openapi_to_nimble_type(%{"properties" => _}), do: ":keyword_list" # allOf composition — if any sub-schema has properties, treat as keyword_list defp openapi_to_nimble_type(%{"allOf" => sub_schemas}) do if Enum.any?(sub_schemas, fn s -> Map.has_key?(s, "properties") or Map.has_key?(s, "$ref") end) do ":keyword_list" else ":any" end end # anyOf — pick a reasonable type defp openapi_to_nimble_type(%{"anyOf" => sub_schemas}) do # Filter out null types non_null = Enum.reject(sub_schemas, fn s -> Map.get(s, "type") == "null" end) case non_null do [single] -> openapi_to_nimble_type(single) _ -> ":any" end end defp openapi_to_nimble_type(%{"const" => _}), do: ":any" defp openapi_to_nimble_type(_), do: ":any" # --------------------------------------------------------------------------- # Doc Generation # --------------------------------------------------------------------------- defp build_doc( summary, http_method, path, tag, path_params, query_param_names, has_body, func_name, has_query_schema, is_binary_response, is_multipart ) do bt = <<96>> parts = [ " @doc \"\"\"", " #{summary}", "", " #{bt}#{http_method} #{path}#{bt}" ] parts = if tag != "", do: parts ++ ["", " Tag: #{tag}"], else: parts parts = if is_binary_response do parts ++ ["", " **Returns binary audio/video data** — the response body is raw bytes (e.g. MP3/WAV)."] else parts end parts = if is_multipart do parts ++ ["", " **Multipart form data** — file uploads are supported via this endpoint."] else parts end parts = if path_params != [] do param_docs = Enum.map(path_params, fn p -> " * #{bt}#{to_snake_case(p)}#{bt} - Path parameter #{bt}#{p}#{bt}" end) parts ++ ["", " ## Path Parameters", ""] ++ param_docs else parts end parts = if has_body do parts ++ [ "", " ## Parameters", "", " Validated by #{bt}NimbleOptions#{bt}. Pass params as a keyword list with snake_case keys.", " See #{bt}@#{func_name}_schema#{bt} for the full schema." ] else parts end parts = if has_query_schema do param_docs = Enum.map(query_param_names, fn p -> " * #{bt}#{to_snake_case(p)}#{bt} — query parameter #{bt}#{p}#{bt}" end) parts ++ ["", " ## Query Parameters", ""] ++ param_docs else parts end parts = parts ++ [ "", " ## Returns", "", " * #{bt}{:ok, %Req.Response{}}#{bt} on success", " * #{bt}{:error, exception}#{bt} on HTTP or validation failure", " \"\"\"" ] Enum.join(parts, "\n") end defp doc_bang(func_name) do bt = <<96>> " @doc \"\"\"\n Bang variant of #{bt}#{func_name}#{bt}. Raises on error.\n \"\"\"" end # --------------------------------------------------------------------------- # Typespec Generation # --------------------------------------------------------------------------- defp build_typespec(func_name, path_params, has_params, bang?) do name = if bang?, do: "#{func_name}!", else: func_name return_type = if bang?, do: "Req.Response.t()", else: "response()" path_types = Enum.map(path_params, fn _ -> "String.t()" end) param_types = if has_params, do: ["keyword()"], else: [] all_types = path_types ++ param_types ++ ["keyword()"] " @spec #{name}(#{Enum.join(all_types, ", ")}) :: #{return_type}" end # --------------------------------------------------------------------------- # Function Body Generation # --------------------------------------------------------------------------- defp build_function_body( func_name, method, path, path_params, _query_param_names, has_body, has_query_schema, is_multipart, bang? ) do elixir_path = build_elixir_path(path, path_params) req_method = String.to_atom(method) fn_name = if bang?, do: "#{func_name}!", else: func_name req_fn = if bang?, do: "#{req_method}!", else: "#{req_method}" # Determine the body option key: :form_multipart for multipart, :json for JSON body_opt = if is_multipart, do: "form_multipart", else: "json" body_fn = if is_multipart, do: "to_multipart", else: "to_body" cond do # POST/PUT/PATCH with body, no path params has_body and path_params == [] -> sig = "def #{fn_name}(params \\\\ [], opts \\\\ []) do" body = if bang? do """ params = NimbleOptions.validate!(params, @#{func_name}_schema) Req.#{req_fn}(client(opts), url: "#{elixir_path}", #{body_opt}: #{body_fn}(params, @#{func_name}_key_mapping)) end """ else """ with {:ok, params} <- NimbleOptions.validate(params, @#{func_name}_schema) do Req.#{req_fn}(client(opts), url: "#{elixir_path}", #{body_opt}: #{body_fn}(params, @#{func_name}_key_mapping)) end end """ end {sig, body} # POST/PUT/PATCH with body and path params has_body and path_params != [] -> args = Enum.map(path_params, &to_snake_case/1) |> Enum.join(", ") sig = "def #{fn_name}(#{args}, params \\\\ [], opts \\\\ []) do" body = if bang? do """ params = NimbleOptions.validate!(params, @#{func_name}_schema) Req.#{req_fn}(client(opts), url: "#{elixir_path}", #{body_opt}: #{body_fn}(params, @#{func_name}_key_mapping)) end """ else """ with {:ok, params} <- NimbleOptions.validate(params, @#{func_name}_schema) do Req.#{req_fn}(client(opts), url: "#{elixir_path}", #{body_opt}: #{body_fn}(params, @#{func_name}_key_mapping)) end end """ end {sig, body} # GET/DELETE with path params and query params (validated) path_params != [] and has_query_schema -> args = Enum.map(path_params, &to_snake_case/1) |> Enum.join(", ") sig = "def #{fn_name}(#{args}, params \\\\ [], opts \\\\ []) do" body = if bang? do """ params = NimbleOptions.validate!(params, @#{func_name}_query_schema) Req.#{req_fn}(client(opts), url: "#{elixir_path}", params: to_query(params, @#{func_name}_query_key_mapping)) end """ else """ with {:ok, params} <- NimbleOptions.validate(params, @#{func_name}_query_schema) do Req.#{req_fn}(client(opts), url: "#{elixir_path}", params: to_query(params, @#{func_name}_query_key_mapping)) end end """ end {sig, body} # GET/DELETE with path params only path_params != [] -> args = Enum.map(path_params, &to_snake_case/1) |> Enum.join(", ") sig = "def #{fn_name}(#{args}, opts \\\\ []) do" body = " Req.#{req_fn}(client(opts), url: \"#{elixir_path}\")\n end\n" {sig, body} # GET/DELETE without path params but with query params (validated) has_query_schema -> sig = "def #{fn_name}(params \\\\ [], opts \\\\ []) do" body = if bang? do """ params = NimbleOptions.validate!(params, @#{func_name}_query_schema) Req.#{req_fn}(client(opts), url: "#{elixir_path}", params: to_query(params, @#{func_name}_query_key_mapping)) end """ else """ with {:ok, params} <- NimbleOptions.validate(params, @#{func_name}_query_schema) do Req.#{req_fn}(client(opts), url: "#{elixir_path}", params: to_query(params, @#{func_name}_query_key_mapping)) end end """ end {sig, body} # No params at all true -> sig = "def #{fn_name}(opts \\\\ []) do" body = " Req.#{req_fn}(client(opts), url: \"#{elixir_path}\")\n end\n" {sig, body} end end defp build_elixir_path(path, []), do: path defp build_elixir_path(path, path_params) do Enum.reduce(path_params, path, fn param, acc -> snake = to_snake_case(param) String.replace(acc, "{#{param}}", "\#{#{snake}}") end) end # --------------------------------------------------------------------------- # Utilities # --------------------------------------------------------------------------- # Returns a valid atom literal string. Quotes values with special chars. defp atom_literal(value) do if Regex.match?(~r/^[a-zA-Z_][a-zA-Z0-9_]*$/, value) do ":#{value}" else ":\"#{value}\"" end end defp to_snake_case(str) do str # Normalize plural acronyms: "URLs" → "Urls", "IDs" → "Ids" |> String.replace(~r/([A-Z]{2,})s/, fn match -> first = String.first(match) rest = match |> String.slice(1..-1//1) |> String.downcase() first <> rest end) |> String.replace(~r/([a-z\d])([A-Z])/, "\\1_\\2") |> String.replace(~r/([A-Z]+)([A-Z][a-z])/, "\\1_\\2") |> String.downcase() |> String.replace(~r/[^a-z0-9_]/, "_") |> String.replace(~r/_+/, "_") |> String.trim_leading("_") |> String.trim_trailing("_") end defp bump_version do content = File.read!(@mix_file) case Regex.run(~r/@version\s+"(\d+)\.(\d+)\.(\d+)"/, content) do [full_match, major, minor, patch] -> new_patch = String.to_integer(patch) + 1 new_version = "#{major}.#{minor}.#{new_patch}" new_content = String.replace(content, full_match, ~s|@version "#{new_version}"|) File.write!(@mix_file, new_content) IO.puts("Bumped version to #{new_version}") _ -> IO.puts("Warning: could not find @version in #{@mix_file} — skipping version bump.") end end end ElevenLabs.Generator.run()