diff --git a/.gitignore b/.gitignore index cd94eac9..8dcca345 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Generated SDKs (pushed to separate repos) /xdk/python/ /xdk/typescript/ +/xdk/ruby/ # Deploy keys (never commit) /.keys/ diff --git a/Makefile b/Makefile index 11db50b5..6a0767d2 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 ruby +.PHONY: test-python test-typescript test-ruby 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 ruby python: cargo run -- python --latest true @@ -24,6 +24,9 @@ python: typescript: cargo run -- typescript --latest true +ruby: + cargo run -- ruby --latest true + # ===================================== # SDK Testing (local dev) # ===================================== @@ -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-ruby: ruby + cd xdk/ruby && bundle install && bundle exec rspec spec/ --format documentation + # ===================================== # Generator # ===================================== @@ -61,14 +67,14 @@ test: test-generator test-sdks # ===================================== versions: - @grep -E "^(python|typescript) = " xdk-config.toml + @grep -E "^(python|typescript|ruby) = " xdk-config.toml # ===================================== # Cleanup # ===================================== clean: - rm -rf xdk/python xdk/typescript + rm -rf xdk/python xdk/typescript xdk/ruby cargo-clean: cargo clean @@ -82,8 +88,10 @@ help: @echo "" @echo "Local Development:" @echo " make python Generate Python SDK" + @echo " make ruby Generate Ruby SDK" @echo " make typescript Generate TypeScript SDK" @echo " make test-python Generate + test Python SDK" + @echo " make test-ruby Generate + test Ruby SDK" @echo " make test-typescript Generate + test TypeScript SDK" @echo "" @echo "Generator:" diff --git a/xdk-build/src/main.rs b/xdk-build/src/main.rs index 2581d90e..2f29732f 100644 --- a/xdk-build/src/main.rs +++ b/xdk-build/src/main.rs @@ -4,6 +4,7 @@ // Declare modules mod error; mod python; +mod ruby; mod typescript; mod utils; @@ -50,6 +51,19 @@ enum Commands { #[arg(short, long, default_value = "xdk/typescript")] output: PathBuf, }, + /// Generate a Ruby SDK from an OpenAPI specification + Ruby { + /// 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/ruby")] + output: PathBuf, + }, } #[tokio::main] @@ -164,6 +178,51 @@ async fn main() -> Result<()> { // Call the generate method - `?` handles the Result conversion typescript::generate(&openapi, &output) } + Commands::Ruby { + 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."); + ruby::generate(&openapi, &output) + } }; // Handle the result with better error messaging diff --git a/xdk-build/src/ruby.rs b/xdk-build/src/ruby.rs new file mode 100644 index 00000000..a821cb37 --- /dev/null +++ b/xdk-build/src/ruby.rs @@ -0,0 +1,61 @@ +use crate::error::{BuildError, Result}; +use colored::*; +use std::path::Path; +use std::process::Command; +use xdk_gen::Ruby; +use xdk_lib::{XdkConfig, log_error, log_info, log_success}; +use xdk_openapi::OpenApi; + +/// Generates the Ruby SDK. +pub fn generate(openapi: &OpenApi, output_dir: &Path) -> Result<()> { + log_info!("Generating Ruby SDK code..."); + + // Load configuration to get version + let config = XdkConfig::load_default().map_err(BuildError::SdkGenError)?; + let version = config.get_version("ruby").ok_or_else(|| { + BuildError::SdkGenError(xdk_lib::SdkGeneratorError::FrameworkError( + "Ruby version not found in config".to_string(), + )) + })?; + + // Create output directory if it doesn't exist + if let Err(e) = std::fs::create_dir_all(output_dir) { + log_error!( + "Failed to create output directory '{}': {}", + output_dir.display(), + e + ); + return Err(BuildError::IoError(e)); + } + + // Generate the SDK code + if let Err(e) = xdk_lib::generator::generate(Ruby, openapi, output_dir, version) { + log_error!("Failed to generate Ruby SDK code: {}", e); + return Err(BuildError::SdkGenError(e)); + } + log_success!("SDK code generated."); + + // Optionally run rubocop for formatting (if available) + if which_exists("rubocop") { + log_info!("Formatting code with rubocop..."); + let mut fmt_cmd = Command::new("rubocop"); + fmt_cmd.arg("-a").arg(output_dir); + // Best-effort formatting; don't fail the build if rubocop isn't configured + let _ = fmt_cmd.output(); + log_success!("Rubocop formatting complete."); + } + + log_success!( + "Successfully generated Ruby SDK in {}", + output_dir.display().to_string().magenta() + ); + Ok(()) +} + +fn which_exists(cmd: &str) -> bool { + Command::new("which") + .arg(cmd) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} diff --git a/xdk-config.toml b/xdk-config.toml index 768ade0a..7a0cfcfd 100644 --- a/xdk-config.toml +++ b/xdk-config.toml @@ -7,3 +7,6 @@ python = "0.9.0" # TypeScript SDK version typescript = "0.5.0" + +# Ruby SDK version +ruby = "0.1.1" diff --git a/xdk-gen/src/lib.rs b/xdk-gen/src/lib.rs index b07e1164..ce24f7e0 100644 --- a/xdk-gen/src/lib.rs +++ b/xdk-gen/src/lib.rs @@ -31,7 +31,9 @@ /// /// See the `python` module for a reference implementation of a language generator. pub use python::Python; +pub use ruby::Ruby; pub use typescript::TypeScript; mod python; +mod ruby; mod typescript; diff --git a/xdk-gen/src/ruby/generator.rs b/xdk-gen/src/ruby/generator.rs new file mode 100644 index 00000000..d07a5dda --- /dev/null +++ b/xdk-gen/src/ruby/generator.rs @@ -0,0 +1,69 @@ +/// Ruby SDK Generator Implementation +/// +/// This file implements the Ruby generator using the `language!` macro. +/// It defines filters for Ruby-specific formatting and implements the generator. +use xdk_lib::{Casing, language, pascal_case}; + +/// MiniJinja filter for converting OpenAPI types to Ruby types +fn ruby_type(value: &str) -> String { + let ruby_type = match value { + "string" => "String", + "integer" => "Integer", + "number" => "Float", + "boolean" => "Boolean", + "array" => "Array", + "object" => "Hash", + _ => "Object", + }; + ruby_type.to_string() +} + +/// MiniJinja filter for getting the last part of a path (splits by both '/' and '.') +fn last_part(value: &str) -> String { + value + .split('/') + .next_back() + .unwrap_or(value) + .split('.') + .next_back() + .unwrap_or(value) + .to_string() +} + +/// Helper function for snake_case conversion (for use as a filter) +fn snake_case(value: &str) -> String { + Casing::Snake.convert_string(value) +} + +/* + This is the main generator for the Ruby SDK + It declares the templates and filters used as well as the rendering logic +*/ +language! { + name: Ruby, + filters: [pascal_case, snake_case, ruby_type, last_part], + class_casing: Casing::Pascal, + operation_casing: Casing::Snake, + import_casing: Casing::Snake, + variable_casing: Casing::Snake, + render: [ + multiple { + render "models" => "lib/xdk/{}/models.rb", + render "client_class" => "lib/xdk/{}/client.rb" + }, + render "main_client" => "lib/xdk/client.rb", + render "version" => "lib/xdk/version.rb", + render "lib_entry" => "lib/xdk.rb", + render "gemspec" => "xdk.gemspec", + render "gemfile" => "Gemfile", + render "readme" => "README.md", + render "gitignore" => ".gitignore" + ], + tests: [ + multiple { + render "test_contracts" => "spec/{}/contracts_spec.rb", + render "test_structure" => "spec/{}/structure_spec.rb" + }, + render "spec_helper" => "spec/spec_helper.rb" + ] +} diff --git a/xdk-gen/src/ruby/mod.rs b/xdk-gen/src/ruby/mod.rs new file mode 100644 index 00000000..77af06d5 --- /dev/null +++ b/xdk-gen/src/ruby/mod.rs @@ -0,0 +1,74 @@ +/// Ruby SDK Generator Module +/// +/// This module implements the SDK generator for Ruby. +mod generator; + +pub use generator::Ruby; + +#[cfg(test)] +mod tests { + use crate::ruby::generator::Ruby; + use std::fs; + use std::path::Path; + use tempfile::Builder; + use xdk_lib::Result; + use xdk_lib::generator::generate; + use xdk_openapi::{OpenApiContextGuard, parse_json_file}; + + fn create_output_dir() -> std::path::PathBuf { + let temp_dir = Builder::new() + .prefix("test_output_ruby") + .tempdir() + .expect("Failed to create temporary directory"); + temp_dir.path().to_path_buf() + } + + fn verify_sdk_structure(output_dir: &Path) { + let lib_dir = output_dir.join("lib").join("xdk"); + assert!(lib_dir.exists(), "lib/xdk directory should exist"); + assert!( + lib_dir.join("client.rb").exists(), + "lib/xdk/client.rb should exist" + ); + assert!( + lib_dir.join("version.rb").exists(), + "lib/xdk/version.rb should exist" + ); + assert!( + output_dir.join("xdk.gemspec").exists(), + "xdk.gemspec should exist" + ); + assert!( + output_dir.join("README.md").exists(), + "README.md should exist" + ); + } + + #[test] + fn test_simple_openapi() { + let output_dir = create_output_dir(); + let _guard = OpenApiContextGuard::new(); + let openapi = parse_json_file("../tests/openapi/simple.json").unwrap(); + let result = generate(Ruby, &openapi, &output_dir, "0.0.1-test"); + assert!(result.is_ok(), "Failed to generate SDK: {:?}", result); + verify_sdk_structure(&output_dir); + } + + #[test] + fn test_version_in_generated_files() { + 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(Ruby, &openapi, &output_dir, test_version); + assert!(result.is_ok(), "Failed to generate SDK: {:?}", result); + + let version_content = fs::read_to_string(output_dir.join("lib/xdk/version.rb")) + .expect("Failed to read version.rb"); + assert!( + version_content.contains("1.2.3-test"), + "version.rb should contain the test version" + ); + } +} diff --git a/xdk-gen/templates/ruby/client_class.j2 b/xdk-gen/templates/ruby/client_class.j2 new file mode 100644 index 00000000..172a13a9 --- /dev/null +++ b/xdk-gen/templates/ruby/client_class.j2 @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# Auto-generated {{ tag.display_name }} client for the X API. +# +# This module provides a client for interacting with the {{ tag.display_name }} +# endpoints of the X API. All methods, parameters, and response handling are +# generated from the OpenAPI specification. +# +# Generated automatically - do not edit manually. + +require "json" +require "net/http" +require "uri" +require_relative "models" + +module Xdk + # Client for {{ tag.display_name }} operations + class {{ tag.class_name }}Client + # @param client [Xdk::Client] the main API client + def initialize(client) + @client = client + end + + {% for operation in operations %} + # {{ operation.summary | default("") }} + {% if operation.description %} + # {{ operation.description }} + {% endif %} + # + {% for param in operation.parameters %} + {% if param.required %} + # @param {{ param.variable_name }} [{{ param.param_type | ruby_type }}] {{ param.description | default("") }} (required) + {% endif %} + {% endfor %} + {% for param in operation.parameters %} + {% if not param.required %} + # @param {{ param.variable_name }} [{{ param.param_type | ruby_type }}, nil] {{ param.description | default("") }} + {% endif %} + {% endfor %} + {% if operation.request_body %} + # @param body [Hash] Request body + {% endif %} + # @return [Hash] API response + def {{ operation.method_name }}( + {%- for param in operation.parameters | selectattr('required') -%} + {{ param.variable_name }}:{% if not loop.last or operation.parameters | rejectattr('required') | list or operation.request_body %},{% endif %} + {%- endfor -%} + {%- if operation.request_body and operation.request_body.required -%} + body:{% if operation.parameters | rejectattr('required') | list %},{% endif %} + {%- endif -%} + {%- for param in operation.parameters | rejectattr('required') -%} + {{ param.variable_name }}: nil{% if not loop.last or (operation.request_body and not operation.request_body.required) %},{% endif %} + {%- endfor -%} + {%- if operation.request_body and not operation.request_body.required -%} + body: nil + {%- endif -%} + ) + # Build the request path + path = "{{ operation.path }}" + {% for param in operation.parameters %} + {% if param.location == "path" %} + path = path.gsub("{{ "{" }}{{ param.original_name }}{{ "}" }}", {{ param.variable_name }}.to_s) + {% endif %} + {% endfor %} + + # Build query parameters + params = {} + {% for param in operation.parameters %} + {% if param.location == "query" %} + {% if param.param_type == "array" %} + params["{{ param.original_name }}"] = {{ param.variable_name }}.is_a?(Array) ? {{ param.variable_name }}.join(",") : {{ param.variable_name }} unless {{ param.variable_name }}.nil? + {% else %} + params["{{ param.original_name }}"] = {{ param.variable_name }} unless {{ param.variable_name }}.nil? + {% endif %} + {% endif %} + {% endfor %} + + {% if operation.request_body %} + # Prepare request body + request_body = body.is_a?(Hash) ? body : (body.nil? ? nil : body) + {% endif %} + + {% if operation.method == "GET" %} + @client.request(method: :get, path: path, params: params) + {% elif operation.method == "POST" %} + @client.request(method: :post, path: path, params: params{% if operation.request_body %}, body: request_body{% endif %}) + {% elif operation.method == "PUT" %} + @client.request(method: :put, path: path, params: params{% if operation.request_body %}, body: request_body{% endif %}) + {% elif operation.method == "DELETE" %} + @client.request(method: :delete, path: path, params: params{% if operation.request_body %}, body: request_body{% endif %}) + {% elif operation.method == "PATCH" %} + @client.request(method: :patch, path: path, params: params{% if operation.request_body %}, body: request_body{% endif %}) + {% else %} + @client.request(method: :{{ operation.method | lower }}, path: path, params: params) + {% endif %} + end + {% endfor %} + end +end diff --git a/xdk-gen/templates/ruby/gemfile.j2 b/xdk-gen/templates/ruby/gemfile.j2 new file mode 100644 index 00000000..30b1e3c5 --- /dev/null +++ b/xdk-gen/templates/ruby/gemfile.j2 @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Auto-generated Gemfile for the X API Ruby SDK. +# Generated automatically - do not edit manually. + +source "https://rubygems.org" + +gemspec + +group :development, :test do + gem "rspec", "~> 3.12" + gem "webmock", "~> 3.19" + gem "rubocop", "~> 1.57" + gem "rubocop-rspec", "~> 2.25" + gem "rake", "~> 13.0" + gem "yard", "~> 0.9" +end diff --git a/xdk-gen/templates/ruby/gemspec.j2 b/xdk-gen/templates/ruby/gemspec.j2 new file mode 100644 index 00000000..478ea423 --- /dev/null +++ b/xdk-gen/templates/ruby/gemspec.j2 @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Auto-generated gem specification for the X API Ruby SDK. +# Generated automatically - do not edit manually. + +require_relative "lib/xdk/version" + +Gem::Specification.new do |spec| + spec.name = "xdk" + spec.version = Xdk::VERSION + spec.summary = "Ruby SDK for the X API" + spec.description = "Auto-generated Ruby SDK for the X (Twitter) API v2. " \ + "Provides full coverage of X API v2 endpoints with bearer token " \ + "and OAuth 1.0a authentication support." + spec.authors = ["X Developer Platform"] + spec.email = ["devs@x.com"] + spec.homepage = "https://github.com/xdevplatform/xdk" + spec.license = "MIT" + + spec.required_ruby_version = ">= 3.4.7" + + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/xdevplatform/xdk" + spec.metadata["changelog_uri"] = "https://github.com/xdevplatform/xdk/releases" + + spec.files = Dir["lib/*.rb", "lib/**/*.rb", "README.md", "LICENSE", "CHANGELOG.md"] + spec.require_paths = ["lib"] + + # No runtime dependencies — uses only Ruby stdlib (net/http, json, uri, openssl) +end diff --git a/xdk-gen/templates/ruby/gitignore.j2 b/xdk-gen/templates/ruby/gitignore.j2 new file mode 100644 index 00000000..e53a0403 --- /dev/null +++ b/xdk-gen/templates/ruby/gitignore.j2 @@ -0,0 +1,33 @@ +# Auto-generated .gitignore for the X API Ruby SDK. +# Generated automatically - do not edit manually. + +# Gem artifacts +*.gem +pkg/ + +# Bundle +.bundle/ +Gemfile.lock +vendor/bundle/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Testing +.rspec_status +coverage/ + +# Documentation +doc/ +.yardoc/ + +# OS files +.DS_Store +Thumbs.db + +# Temp +tmp/ diff --git a/xdk-gen/templates/ruby/lib_entry.j2 b/xdk-gen/templates/ruby/lib_entry.j2 new file mode 100644 index 00000000..a103ef15 --- /dev/null +++ b/xdk-gen/templates/ruby/lib_entry.j2 @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# XDK Ruby SDK +# +# A Ruby SDK for the X API that provides convenient access +# to the X API endpoints with bearer token and OAuth 1.0a support. + +require_relative "xdk/version" +require_relative "xdk/client" diff --git a/xdk-gen/templates/ruby/main_client.j2 b/xdk-gen/templates/ruby/main_client.j2 new file mode 100644 index 00000000..d1641c72 --- /dev/null +++ b/xdk-gen/templates/ruby/main_client.j2 @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +# Auto-generated main client for the X API. +# +# This module provides the primary Client class for interacting with the X API. +# It coordinates all sub-clients and handles authentication and HTTP requests. +# All functionality is generated from the OpenAPI specification. +# +# Generated automatically - do not edit manually. + +require "net/http" +require "uri" +require "json" +require "openssl" +require "cgi" +require "securerandom" +require "base64" + +{% for tag in tags %} +require_relative "{{ tag.import_name }}/client" +{% endfor %} +require_relative "version" + +module Xdk + # Error raised when an API request fails + class ApiError < StandardError + # @return [Integer] HTTP status code + attr_reader :status + + # @return [Hash, String, nil] parsed response body + attr_reader :body + + # @return [Hash] response headers + attr_reader :headers + + def initialize(message, status:, body: nil, headers: {}) + @status = status + @body = body + @headers = headers + super("#{message} (HTTP #{status})") + end + end + + # Rate limit error with retry-after information + class RateLimitError < ApiError + # @return [Integer, nil] seconds until the rate limit resets + attr_reader :retry_after + + def initialize(message, status:, body: nil, headers: {}) + @retry_after = headers["x-rate-limit-reset"]&.to_i + super + end + end + + # Main client for interacting with the X API. + # + # @example Using bearer token (app-only auth) + # client = Xdk::Client.new(bearer_token: "YOUR_BEARER_TOKEN") + # result = client.posts.search_posts_recent(query: "ruby lang") + # + # @example Using OAuth 1.0a (user-context auth) + # client = Xdk::Client.new( + # api_key: "YOUR_API_KEY", + # api_secret: "YOUR_API_SECRET", + # access_token: "YOUR_ACCESS_TOKEN", + # access_token_secret: "YOUR_ACCESS_TOKEN_SECRET" + # ) + class Client + BASE_URL = "https://api.x.com" + + # @return [String] the base URL for API requests + attr_reader :base_url + + # Initialize the X API client. + # + # @param bearer_token [String, nil] Bearer token for app-only authentication + # @param api_key [String, nil] OAuth 1.0a consumer API key + # @param api_secret [String, nil] OAuth 1.0a consumer API secret + # @param access_token [String, nil] OAuth 1.0a access token + # @param access_token_secret [String, nil] OAuth 1.0a access token secret + # @param base_url [String] Base URL for the X API + def initialize( + bearer_token: nil, + api_key: nil, + api_secret: nil, + access_token: nil, + access_token_secret: nil, + base_url: BASE_URL + ) + @bearer_token = bearer_token + @api_key = api_key + @api_secret = api_secret + @access_token = access_token + @access_token_secret = access_token_secret + @base_url = base_url + end + + {% for tag in tags %} + # Access the {{ tag.display_name }} API + # @return [{{ tag.class_name }}Client] + def {{ tag.property_name }} + @{{ tag.property_name }} ||= {{ tag.class_name }}Client.new(self) + end + {% endfor %} + + # Execute an API request. + # + # @param method [Symbol] HTTP method (:get, :post, :put, :delete, :patch) + # @param path [String] API path (e.g., "/2/tweets") + # @param params [Hash] query parameters + # @param body [Hash, nil] request body (will be JSON-encoded) + # @param headers [Hash] additional headers + # @return [Hash] parsed JSON response + # @raise [ApiError] if the response status is not 2xx + # @raise [RateLimitError] if rate limited (HTTP 429) + # @api private + def request(method:, path:, params: {}, body: nil, headers: {}) + uri = URI.join(@base_url, path) + + # Add query parameters + clean_params = params.compact + unless clean_params.empty? + uri.query = URI.encode_www_form(clean_params) + end + + # Set up HTTPS connection + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = true + http.open_timeout = 30 + http.read_timeout = 30 + + # Build request object + req = build_request(method, uri) + + # Set default headers + req["User-Agent"] = "xdk-ruby/#{Xdk::VERSION}" + req["Content-Type"] = "application/json" + req["Accept"] = "application/json" + + # Set authentication + set_authentication(req) + + # Merge additional headers + headers.each { |k, v| req[k.to_s] = v.to_s } + + # Set body for non-GET requests + if body && ![:get, :head].include?(method) + req.body = body.is_a?(String) ? body : body.to_json + end + + # Execute request + response = http.request(req) + + # Handle response + handle_response(response) + end + + private + + # Build the appropriate Net::HTTP request object + def build_request(method, uri) + case method.to_s.upcase + when "GET" then Net::HTTP::Get.new(uri) + when "POST" then Net::HTTP::Post.new(uri) + when "PUT" then Net::HTTP::Put.new(uri) + when "DELETE" then Net::HTTP::Delete.new(uri) + when "PATCH" then Net::HTTP::Patch.new(uri) + else raise ArgumentError, "Unsupported HTTP method: #{method}" + end + end + + # Set authentication headers on the request + def set_authentication(req) + if @bearer_token + req["Authorization"] = "Bearer #{@bearer_token}" + elsif @api_key && @api_secret && @access_token && @access_token_secret + req["Authorization"] = build_oauth1_header(req) + end + end + + # Build OAuth 1.0a Authorization header + def build_oauth1_header(req) + timestamp = Time.now.to_i.to_s + nonce = SecureRandom.hex(16) + + oauth_params = { + "oauth_consumer_key" => @api_key, + "oauth_nonce" => nonce, + "oauth_signature_method" => "HMAC-SHA1", + "oauth_timestamp" => timestamp, + "oauth_token" => @access_token, + "oauth_version" => "1.0" + } + + # Build signature base string + method = req.method.upcase + base_url = "#{req.uri.scheme}://#{req.uri.host}#{req.uri.path}" + + # Combine OAuth params and query params + all_params = oauth_params.dup + if req.uri.query + URI.decode_www_form(req.uri.query).each { |k, v| all_params[k] = v } + end + + param_string = all_params.sort.map { |k, v| "#{percent_encode(k)}=#{percent_encode(v)}" }.join("&") + base_string = "#{method}&#{percent_encode(base_url)}&#{percent_encode(param_string)}" + + # Sign + signing_key = "#{percent_encode(@api_secret)}&#{percent_encode(@access_token_secret)}" + signature = Base64.strict_encode64( + OpenSSL::HMAC.digest("SHA1", signing_key, base_string) + ) + + oauth_params["oauth_signature"] = signature + + # Build header + header_parts = oauth_params.sort.map { |k, v| "#{percent_encode(k)}=\"#{percent_encode(v)}\"" } + "OAuth #{header_parts.join(", ")}" + end + + # RFC 3986 percent-encoding + def percent_encode(value) + CGI.escape(value.to_s).gsub("+", "%20") + end + + # Handle the HTTP response, raising appropriate errors for non-2xx statuses + def handle_response(response) + body = parse_response_body(response) + response_headers = {} + response.each_header { |k, v| response_headers[k] = v } + + case response.code.to_i + when 200..299 + body + when 429 + raise RateLimitError.new( + "Rate limit exceeded", + status: response.code.to_i, + body: body, + headers: response_headers + ) + else + message = if body.is_a?(Hash) + body.dig("detail") || body.dig("errors", 0, "message") || "API request failed" + else + "API request failed" + end + raise ApiError.new( + message, + status: response.code.to_i, + body: body, + headers: response_headers + ) + end + end + + # Parse the response body as JSON, falling back to raw string + def parse_response_body(response) + return nil if response.body.nil? || response.body.empty? + JSON.parse(response.body) + rescue JSON::ParserError + response.body + end + end +end diff --git a/xdk-gen/templates/ruby/models.j2 b/xdk-gen/templates/ruby/models.j2 new file mode 100644 index 00000000..6405c031 --- /dev/null +++ b/xdk-gen/templates/ruby/models.j2 @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +# Auto-generated {{ tag.display_name }} models for the X API. +# +# This module provides lightweight model classes for request and response +# data structures for the {{ tag.display_name }} endpoints. All models are +# generated from the OpenAPI specification and provide convenient attribute +# access over raw Hash data. +# +# Generated automatically - do not edit manually. + +module Xdk + module {{ tag.class_name }} + {% for operation in operations %} + {% if operation.responses and "200" in operation.responses %} + # Response model for {{ operation.method_name }} + # + # Wraps the raw API response Hash and provides attribute-style access + # to common top-level fields (data, meta, errors, includes). + class {{ operation.class_name }}Response + # @return [Object, nil] the primary response data + attr_reader :data + + # @return [Hash, nil] pagination metadata (next_token, result_count, etc.) + attr_reader :meta + + # @return [Array, nil] error objects returned by the API + attr_reader :errors + + # @return [Hash, nil] expansion includes (users, tweets, media, etc.) + attr_reader :includes + + # @return [Hash] the complete raw API response + attr_reader :raw + + # @param hash [Hash] the parsed JSON response from the API + def initialize(hash) + hash = {} unless hash.is_a?(Hash) + @raw = hash + @data = hash["data"] + @meta = hash["meta"] + @errors = hash["errors"] + @includes = hash["includes"] + end + + # Access any top-level key from the raw response + # @param key [String] the key to look up + # @return [Object, nil] + def [](key) + @raw[key] + end + + # @return [String] + def to_s + "#<{{ operation.class_name }}Response data=#{@data.class}>" + end + + # @return [String] + def inspect + "#<{{ operation.class_name }}Response data=#{@data.inspect} meta=#{@meta.inspect}>" + end + end + {% endif %} + + {% if operation.request_body %} + # Request model for {{ operation.method_name }} + # + # Provides a structured way to build request bodies. Can be initialized + # with a Hash or keyword arguments; serializes to a Hash for the HTTP layer. + class {{ operation.class_name }}Request + # @return [Hash] the raw request data + attr_reader :data + + # @param attrs [Hash] request body attributes + def initialize(**attrs) + @data = attrs.transform_keys(&:to_s) + end + + # Convert to a Hash suitable for JSON serialization + # @return [Hash] + def to_h + @data + end + + # Convert to JSON string + # @return [String] + def to_json(*args) + @data.to_json(*args) + end + + # @return [String] + def to_s + "#<{{ operation.class_name }}Request #{@data}>" + end + end + {% endif %} + {% endfor %} + end +end diff --git a/xdk-gen/templates/ruby/readme.j2 b/xdk-gen/templates/ruby/readme.j2 new file mode 100644 index 00000000..dea6a4a9 --- /dev/null +++ b/xdk-gen/templates/ruby/readme.j2 @@ -0,0 +1,85 @@ +# XDK Ruby SDK + + + +A Ruby SDK for the X API v2. + +## Installation + +Add to your Gemfile: + +```ruby +gem "xdk", "~> {{ version }}" +``` + +Or install directly: + +```bash +gem install xdk +``` + +## Quick Start + +### Bearer Token (App-Only Auth) + +```ruby +require "xdk" + +client = Xdk::Client.new(bearer_token: ENV["X_BEARER_TOKEN"]) + +# Search recent posts +result = client.posts.search_posts_recent(query: "ruby programming") +puts result["data"] + +# Get a user by username +user = client.users.find_user_by_username(username: "ruby") +puts user["data"]["name"] +``` + +### OAuth 1.0a (User-Context Auth) + +```ruby +require "xdk" + +client = Xdk::Client.new( + api_key: ENV["X_API_KEY"], + api_secret: ENV["X_API_SECRET"], + access_token: ENV["X_ACCESS_TOKEN"], + access_token_secret: ENV["X_ACCESS_TOKEN_SECRET"] +) + +# Create a post +client.posts.create_tweet(body: { text: "Hello from Ruby! 💎" }) +``` + +## Features + +- Full coverage of X API v2 endpoints +- Bearer token (OAuth 2.0 App-Only) authentication +- OAuth 1.0a user-context authentication +- Zero runtime dependencies (uses Ruby stdlib only) +- Structured error handling with `Xdk::ApiError` and `Xdk::RateLimitError` +- Rate limit detection with retry-after information + +## Error Handling + +```ruby +begin + client.posts.find_tweet_by_id(id: "invalid") +rescue Xdk::RateLimitError => e + puts "Rate limited! Retry after: #{e.retry_after} seconds" +rescue Xdk::ApiError => e + puts "API error: #{e.message} (status: #{e.status})" +end +``` + +## Documentation + +For more information, see the [X API documentation](https://docs.x.com). + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. diff --git a/xdk-gen/templates/ruby/spec_helper.j2 b/xdk-gen/templates/ruby/spec_helper.j2 new file mode 100644 index 00000000..f982dad0 --- /dev/null +++ b/xdk-gen/templates/ruby/spec_helper.j2 @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Auto-generated spec helper for the X API Ruby SDK. +# Generated automatically - do not edit manually. + +require "webmock/rspec" +require "json" +require "securerandom" +require "erb" + +# Require the main library entry point +require_relative "../lib/xdk/version" +require_relative "../lib/xdk/client" + +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.order = :random + Kernel.srand config.seed +end + +# Disable all real network connections in tests +WebMock.disable_net_connect! diff --git a/xdk-gen/templates/ruby/test_contracts.j2 b/xdk-gen/templates/ruby/test_contracts.j2 new file mode 100644 index 00000000..849157ba --- /dev/null +++ b/xdk-gen/templates/ruby/test_contracts.j2 @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +# Auto-generated contract tests for {{ tag.display_name }}. +# +# This module contains tests that validate the request/response contracts +# of the {{ tag.display_name }} client against the OpenAPI specification. +# +# Generated automatically - do not edit manually. + +require "spec_helper" + +RSpec.describe Xdk::{{ tag.class_name }}Client do + let(:client) { Xdk::Client.new(bearer_token: "test-bearer-token") } + let(:api) { client.{{ tag.property_name }} } + + {% for operation in operations %} + describe "#{{ operation.method_name }}" do + it "makes a {{ operation.method }} request to the correct path" do + stub_request(:{{ operation.method | lower }}, /api\.x\.com/) + .to_return( + status: 200, + body: '{"data": {}, "meta": {}}', + headers: { "Content-Type" => "application/json" } + ) + + result = api.{{ operation.method_name }}( + {%- for param in operation.parameters | selectattr('required') -%} + {{ param.variable_name }}: "test_value"{% if not loop.last %}, {% endif %} + {%- endfor -%} + {%- if operation.request_body and operation.request_body.required -%} + {% if operation.parameters | selectattr('required') | list %}, {% endif %}body: { text: "test" } + {%- endif -%} + ) + + expect(result).to be_a(Hash) + expect(a_request(:{{ operation.method | lower }}, /api\.x\.com/)).to have_been_made.once + end + + it "sends the correct Authorization header" do + stub = stub_request(:{{ operation.method | lower }}, /api\.x\.com/) + .with(headers: { "Authorization" => "Bearer test-bearer-token" }) + .to_return( + status: 200, + body: '{"data": {}}', + headers: { "Content-Type" => "application/json" } + ) + + api.{{ operation.method_name }}( + {%- for param in operation.parameters | selectattr('required') -%} + {{ param.variable_name }}: "test_value"{% if not loop.last %}, {% endif %} + {%- endfor -%} + {%- if operation.request_body and operation.request_body.required -%} + {% if operation.parameters | selectattr('required') | list %}, {% endif %}body: { text: "test" } + {%- endif -%} + ) + + expect(stub).to have_been_requested.once + end + + it "sends the correct User-Agent header" do + stub = stub_request(:{{ operation.method | lower }}, /api\.x\.com/) + .with(headers: { "User-Agent" => "xdk-ruby/#{Xdk::VERSION}" }) + .to_return( + status: 200, + body: '{"data": {}}', + headers: { "Content-Type" => "application/json" } + ) + + api.{{ operation.method_name }}( + {%- for param in operation.parameters | selectattr('required') -%} + {{ param.variable_name }}: "test_value"{% if not loop.last %}, {% endif %} + {%- endfor -%} + {%- if operation.request_body and operation.request_body.required -%} + {% if operation.parameters | selectattr('required') | list %}, {% endif %}body: { text: "test" } + {%- endif -%} + ) + + expect(stub).to have_been_requested.once + end + + it "raises ApiError on 4xx responses" do + stub_request(:{{ operation.method | lower }}, /api\.x\.com/) + .to_return( + status: 404, + body: '{"detail": "Not Found"}', + headers: { "Content-Type" => "application/json" } + ) + + expect { + api.{{ operation.method_name }}( + {%- for param in operation.parameters | selectattr('required') -%} + {{ param.variable_name }}: "test_value"{% if not loop.last %}, {% endif %} + {%- endfor -%} + {%- if operation.request_body and operation.request_body.required -%} + {% if operation.parameters | selectattr('required') | list %}, {% endif %}body: { text: "test" } + {%- endif -%} + ) + }.to raise_error(Xdk::ApiError) { |error| + expect(error.status).to eq(404) + } + end + + it "raises RateLimitError on 429 responses" do + stub_request(:{{ operation.method | lower }}, /api\.x\.com/) + .to_return( + status: 429, + body: '{"detail": "Too Many Requests"}', + headers: { + "Content-Type" => "application/json", + "x-rate-limit-reset" => "1234567890" + } + ) + + expect { + api.{{ operation.method_name }}( + {%- for param in operation.parameters | selectattr('required') -%} + {{ param.variable_name }}: "test_value"{% if not loop.last %}, {% endif %} + {%- endfor -%} + {%- if operation.request_body and operation.request_body.required -%} + {% if operation.parameters | selectattr('required') | list %}, {% endif %}body: { text: "test" } + {%- endif -%} + ) + }.to raise_error(Xdk::RateLimitError) { |error| + expect(error.status).to eq(429) + expect(error.retry_after).to eq(1_234_567_890) + } + end + {% for param in operation.parameters | selectattr('required') %} + {% if param.location == "query" %} + + it "passes {{ param.variable_name }} as a query parameter" do + stub = stub_request(:{{ operation.method | lower }}, /api\.x\.com/) + .with(query: hash_including("{{ param.original_name }}" => "my_test_val")) + .to_return( + status: 200, + body: '{"data": {}}', + headers: { "Content-Type" => "application/json" } + ) + + api.{{ operation.method_name }}( + {%- for p in operation.parameters | selectattr('required') -%} + {% if p.variable_name == param.variable_name %}{{ p.variable_name }}: "my_test_val"{% else %}{{ p.variable_name }}: "test_value"{% endif %}{% if not loop.last %}, {% endif %} + {%- endfor -%} + {%- if operation.request_body and operation.request_body.required -%} + {% if operation.parameters | selectattr('required') | list %}, {% endif %}body: { text: "test" } + {%- endif -%} + ) + + expect(stub).to have_been_requested.once + end + {% endif %} + {% endfor %} + end + {% endfor %} +end diff --git a/xdk-gen/templates/ruby/test_structure.j2 b/xdk-gen/templates/ruby/test_structure.j2 new file mode 100644 index 00000000..5a21593d --- /dev/null +++ b/xdk-gen/templates/ruby/test_structure.j2 @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# Auto-generated structure tests for {{ tag.display_name }}. +# +# This module contains tests that validate the structure and API surface +# of the {{ tag.display_name }} client. These tests ensure that all expected +# methods exist and are callable. +# +# Generated automatically - do not edit manually. + +require "spec_helper" + +RSpec.describe "{{ tag.display_name }} SDK structure" do + let(:client) { Xdk::Client.new(bearer_token: "test-token") } + + describe Xdk::{{ tag.class_name }}Client do + it "is accessible from the main client via #{{ tag.property_name }}" do + expect(client.{{ tag.property_name }}).to be_a(Xdk::{{ tag.class_name }}Client) + end + + it "returns the same instance on repeated access (memoized)" do + expect(client.{{ tag.property_name }}).to equal(client.{{ tag.property_name }}) + end + + {% for operation in operations %} + it "responds to #{{ operation.method_name }}" do + expect(client.{{ tag.property_name }}).to respond_to(:{{ operation.method_name }}) + end + {% endfor %} + + it "has at least one public API method" do + public_methods = Xdk::{{ tag.class_name }}Client.public_instance_methods(false) - [:initialize] + expect(public_methods).not_to be_empty + end + + describe "method arity" do + {% for operation in operations %} + {% set required_count = operation.parameters | selectattr('required') | list | length %} + it "#{{ operation.method_name }} accepts keyword arguments" do + method_obj = client.{{ tag.property_name }}.method(:{{ operation.method_name }}) + # Method should accept keyword arguments + params = method_obj.parameters + keyword_params = params.select { |type, _| [:key, :keyreq].include?(type) } + expect(keyword_params).not_to be_empty, "{{ operation.method_name }} should accept keyword arguments" + end + {% endfor %} + end + end + + describe "Module structure" do + it "defines Xdk::{{ tag.class_name }}Client" do + expect(defined?(Xdk::{{ tag.class_name }}Client)).to eq("constant") + end + + it "defines the {{ tag.class_name }} models module" do + expect(defined?(Xdk::{{ tag.class_name }})).to eq("constant") + end + end +end diff --git a/xdk-gen/templates/ruby/version.j2 b/xdk-gen/templates/ruby/version.j2 new file mode 100644 index 00000000..19a20acb --- /dev/null +++ b/xdk-gen/templates/ruby/version.j2 @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Auto-generated version file for the X API Ruby SDK. +# Generated automatically - do not edit manually. + +module Xdk + VERSION = "{{ version }}" +end diff --git a/xdk-lib/src/templates.rs b/xdk-lib/src/templates.rs index 03a70e29..07c93757 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(".rb") || path.ends_with(".gemspec") { + "ruby" } 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 == "ruby" { ("", "#", "") } else if file_type == "typescript" { ("", "//", "") @@ -131,6 +133,14 @@ fn get_header_for_file(template_name: &str, output_path: Option<&str>) -> String } else if template_name == "npmignore" || template_name.ends_with("ignore") { // Ignore files use # comments ("", "#", "") + } else if template_name == "gemspec" + || template_name == "gemfile" + || template_name == "spec_helper" + || template_name.contains("_spec") + || template_name == "version" + { + // Ruby files use # comments + ("", "#", "") } else { // Default: use // for unknown types ("", "//", "") @@ -232,4 +242,18 @@ mod tests { assert!(header.contains("# AUTO-GENERATED FILE")); assert!(!header.contains("// AUTO-GENERATED FILE")); } + + #[test] + fn test_ruby_header() { + let header = get_header_for_file("client_class", Some("lib/xdk/client.rb")); + assert!(header.contains("# AUTO-GENERATED FILE")); + assert!(!header.contains("// AUTO-GENERATED FILE")); + } + + #[test] + fn test_gemspec_header() { + let header = get_header_for_file("gemspec", Some("xdk.gemspec")); + assert!(header.contains("# AUTO-GENERATED FILE")); + assert!(!header.contains("// AUTO-GENERATED FILE")); + } }