From bfe1809fb9e44849f88676fb1ab7f83c9621cc31 Mon Sep 17 00:00:00 2001 From: Taylor Caldwell Date: Wed, 21 Jan 2026 14:56:00 -0800 Subject: [PATCH 1/2] Address bugs --- latest-openapi.json | 848 +++++++++++++++++- xdk-gen/templates/typescript/client_class.j2 | 68 +- xdk-gen/templates/typescript/crypto_utils.j2 | 312 ++++++- xdk-gen/templates/typescript/http_client.j2 | 31 +- xdk-gen/templates/typescript/main_client.j2 | 99 +- xdk-gen/templates/typescript/oauth2_auth.j2 | 90 ++ xdk-gen/templates/typescript/package_json.j2 | 14 +- xdk-gen/templates/typescript/paginator.j2 | 12 +- .../typescript/process_for_mintlify.j2 | 2 +- xdk-gen/templates/typescript/readme.j2 | 19 +- 10 files changed, 1423 insertions(+), 72 deletions(-) diff --git a/latest-openapi.json b/latest-openapi.json index 1d4c8ba5..e1892710 100644 --- a/latest-openapi.json +++ b/latest-openapi.json @@ -2,7 +2,7 @@ "openapi" : "3.0.0", "info" : { "description" : "X API v2 available endpoints", - "version" : "2.152", + "version" : "2.157", "title" : "X API v2", "termsOfService" : "https://developer.x.com/en/developer-terms/agreement-and-policy.html", "contact" : { @@ -1077,6 +1077,124 @@ } } }, + "/2/connections" : { + "get" : { + "security" : [ + { + "BearerToken" : [ ] + } + ], + "tags" : [ + "Connections" + ], + "summary" : "Get Connection History", + "description" : "Returns active and historical streaming connections with disconnect reasons for the authenticated application.", + "operationId" : "getConnectionHistory", + "parameters" : [ + { + "name" : "status", + "in" : "query", + "description" : "Filter by connection status. Use 'active' for current connections, 'inactive' for historical/disconnected connections, or 'all' for both.", + "required" : false, + "schema" : { + "type" : "string", + "enum" : [ + "active", + "inactive", + "all" + ], + "default" : "active" + }, + "style" : "form" + }, + { + "name" : "endpoints", + "in" : "query", + "description" : "Filter by streaming endpoint. Specify one or more endpoint names to filter results.", + "required" : false, + "schema" : { + "type" : "array", + "uniqueItems" : true, + "items" : { + "type" : "string", + "enum" : [ + "filter_stream", + "sample_stream", + "sample10_stream", + "firehose_stream", + "tweets_compliance_stream", + "users_compliance_stream", + "tweet_label_stream", + "firehose_stream_lang_en", + "firehose_stream_lang_ja", + "firehose_stream_lang_ko", + "firehose_stream_lang_pt", + "likes_firehose_stream", + "likes_sample10_stream", + "likes_compliance_stream" + ] + } + }, + "explode" : false, + "style" : "form" + }, + { + "name" : "max_results", + "in" : "query", + "description" : "The maximum number of results to return per page.", + "required" : false, + "schema" : { + "type" : "integer", + "minimum" : 1, + "maximum" : 100, + "format" : "int32", + "default" : 10 + }, + "style" : "form" + }, + { + "name" : "pagination_token", + "in" : "query", + "description" : "Token for paginating through results. Use the value from 'next_token' in the previous response.", + "required" : false, + "schema" : { + "type" : "string" + }, + "style" : "form" + }, + { + "$ref" : "#/components/parameters/ConnectionFieldsParameter" + } + ], + "responses" : { + "200" : { + "description" : "The request has succeeded.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Get2ConnectionsResponse" + } + } + } + }, + "default" : { + "description" : "The request has failed.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Error" + } + }, + "application/problem+json" : { + "schema" : { + "$ref" : "#/components/schemas/Problem" + } + } + } + } + } + } + }, "/2/connections/all" : { "delete" : { "security" : [ @@ -3980,6 +4098,174 @@ } } }, + "/2/news/search" : { + "get" : { + "security" : [ + { + "BearerToken" : [ ] + }, + { + "OAuth2UserToken" : [ + "tweet.read", + "users.read" + ] + } + ], + "tags" : [ + "News" + ], + "summary" : "Search News", + "description" : "Retrieves a list of News stories matching the specified search query.", + "externalDocs" : { + "url" : "https://docs.x.com/x-api/news/introduction" + }, + "operationId" : "searchNews", + "parameters" : [ + { + "name" : "query", + "in" : "query", + "description" : "The search query.", + "required" : true, + "example" : "crypto", + "schema" : { + "type" : "string", + "minLength" : 1, + "maxLength" : 2048, + "example" : "crypto" + }, + "style" : "form" + }, + { + "name" : "max_results", + "in" : "query", + "description" : "The number of results to return.", + "required" : false, + "schema" : { + "type" : "integer", + "minimum" : 1, + "maximum" : 100, + "format" : "int32", + "default" : 10 + }, + "style" : "form" + }, + { + "name" : "max_age_hours", + "in" : "query", + "description" : "The maximum age of the News story to search for.", + "required" : false, + "schema" : { + "type" : "integer", + "minimum" : 1, + "maximum" : 720, + "format" : "int32", + "default" : 168 + }, + "style" : "form" + }, + { + "$ref" : "#/components/parameters/NewsFieldsParameter" + } + ], + "responses" : { + "200" : { + "description" : "The request has succeeded.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Get2NewsSearchResponse" + } + } + } + }, + "default" : { + "description" : "The request has failed.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Error" + } + }, + "application/problem+json" : { + "schema" : { + "$ref" : "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/2/news/{id}" : { + "get" : { + "security" : [ + { + "BearerToken" : [ ] + }, + { + "OAuth2UserToken" : [ + "tweet.read", + "users.read" + ] + }, + { + "UserToken" : [ ] + } + ], + "tags" : [ + "News" + ], + "summary" : "Get news stories by ID", + "description" : "Retrieves news story by its ID.", + "externalDocs" : { + "url" : "https://docs.x.com/x-api/news/introduction" + }, + "operationId" : "getNews", + "parameters" : [ + { + "name" : "id", + "in" : "path", + "description" : "The ID of the news story.", + "required" : true, + "example" : "119929381293", + "schema" : { + "$ref" : "#/components/schemas/NewsId" + }, + "style" : "simple" + }, + { + "$ref" : "#/components/parameters/NewsFieldsParameter" + } + ], + "responses" : { + "200" : { + "description" : "The request has succeeded.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Get2NewsIdResponse" + } + } + } + }, + "default" : { + "description" : "The request has failed.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Error" + } + }, + "application/problem+json" : { + "schema" : { + "$ref" : "#/components/schemas/Problem" + } + } + } + } + } + } + }, "/2/notes" : { "post" : { "security" : [ @@ -4185,6 +4471,16 @@ }, "style" : "form" }, + { + "name" : "post_selection", + "in" : "query", + "description" : "The selection of posts to return. Valid values are 'feed_size: small' and 'feed_size: large'. Default is 'feed_size: small', only top AI writers have access to large size feed.", + "required" : false, + "schema" : { + "type" : "string" + }, + "style" : "form" + }, { "$ref" : "#/components/parameters/TweetFieldsParameter" }, @@ -5340,7 +5636,7 @@ { "name" : "start_time", "in" : "query", - "description" : "YYYY-MM-DDTHH:mm:ssZ. The oldest UTC timestamp (from most recent 7 days) from which the Posts will be provided. Timestamp is in second granularity and is inclusive (i.e. 12:00:01 includes the first second of the minute).", + "description" : "YYYY-MM-DDTHH:mm:ssZ. The oldest UTC timestamp from which the Posts will be provided. Timestamp is in second granularity and is inclusive (i.e. 12:00:01 includes the first second of the minute).", "required" : false, "schema" : { "type" : "string", @@ -11671,27 +11967,82 @@ } } }, - "/2/webhooks/{webhook_id}" : { - "delete" : { + "/2/webhooks/replay" : { + "post" : { "security" : [ { "BearerToken" : [ ] - }, - { - "UserToken" : [ ] } ], "tags" : [ "Webhooks" ], - "summary" : "Delete webhook", - "description" : "Deletes an existing webhook configuration.", + "summary" : "Create replay job for webhook", + "description" : "Creates a replay job to retrieve events from up to the past 24 hours for all events delivered or attempted to be delivered to the webhook.", "externalDocs" : { "url" : "https://docs.x.com/x-api/webhooks/introduction" }, - "operationId" : "deleteWebhooks", - "parameters" : [ - { + "operationId" : "createWebhookReplayJob", + "parameters" : [ ], + "requestBody" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/WebhookReplayCreateRequest" + } + } + } + }, + "responses" : { + "200" : { + "description" : "The request has succeeded.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ReplayJobCreateResponse" + } + } + } + }, + "default" : { + "description" : "The request has failed.", + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/Error" + } + }, + "application/problem+json" : { + "schema" : { + "$ref" : "#/components/schemas/Problem" + } + } + } + } + } + } + }, + "/2/webhooks/{webhook_id}" : { + "delete" : { + "security" : [ + { + "BearerToken" : [ ] + }, + { + "UserToken" : [ ] + } + ], + "tags" : [ + "Webhooks" + ], + "summary" : "Delete webhook", + "description" : "Deletes an existing webhook configuration.", + "externalDocs" : { + "url" : "https://docs.x.com/x-api/webhooks/introduction" + }, + "operationId" : "deleteWebhooks", + "parameters" : [ + { "name" : "webhook_id", "in" : "path", "description" : "The ID of the webhook to delete.", @@ -11869,6 +12220,14 @@ "url" : "https://developer.x.com" } }, + { + "name" : "News", + "description" : "Endpoint for retrieving news stories", + "externalDocs" : { + "description" : "Find out more", + "url" : "https://developer.twitter.com/en/docs/twitter-api/news" + } + }, { "name" : "Spaces", "description" : "Endpoints related to retrieving, managing Spaces", @@ -11946,6 +12305,12 @@ } }, "schemas" : { + "ActivityEventId" : { + "type" : "string", + "description" : "The unique identifier of an Activity event.", + "pattern" : "^[0-9]{1,19}$", + "example" : "1146654567674912769" + }, "ActivityStreamingResponse" : { "type" : "object", "description" : "An activity event or error that can be returned by the x activity streaming API.", @@ -11956,19 +12321,14 @@ "event_type" : { "type" : "string" }, + "event_uuid" : { + "$ref" : "#/components/schemas/ActivityEventId" + }, "filter" : { "$ref" : "#/components/schemas/ActivitySubscriptionFilter" }, "payload" : { - "type" : "object", - "properties" : { - "after" : { - "type" : "string" - }, - "before" : { - "type" : "string" - } - } + "$ref" : "#/components/schemas/ActivityStreamingResponsePayload" }, "tag" : { "type" : "string" @@ -11984,6 +12344,34 @@ } } }, + "ActivityStreamingResponsePayload" : { + "oneOf" : [ + { + "$ref" : "#/components/schemas/ProfileUpdateActivityResponsePayload" + }, + { + "$ref" : "#/components/schemas/NewsActivityResponsePayload" + }, + { + "$ref" : "#/components/schemas/FollowActivityResponsePayload" + } + ], + "discriminator" : { + "propertyName" : "../event_type", + "mapping" : { + "follow.follow" : "#/components/schemas/FollowActivityResponsePayload", + "follow.unfollow" : "#/components/schemas/FollowActivityResponsePayload", + "news.new" : "#/components/schemas/NewsActivityResponsePayload", + "profile.update.banner_picture" : "#/components/schemas/ProfileUpdateActivityResponsePayload", + "profile.update.bio" : "#/components/schemas/ProfileUpdateActivityResponsePayload", + "profile.update.geo" : "#/components/schemas/ProfileUpdateActivityResponsePayload", + "profile.update.profile_picture" : "#/components/schemas/ProfileUpdateActivityResponsePayload", + "profile.update.screenname" : "#/components/schemas/ProfileUpdateActivityResponsePayload", + "profile.update.url" : "#/components/schemas/ProfileUpdateActivityResponsePayload", + "profile.update.verified_badge" : "#/components/schemas/ProfileUpdateActivityResponsePayload" + } + } + }, "ActivitySubscription" : { "type" : "object", "description" : "An XActivity subscription.", @@ -12030,6 +12418,16 @@ "event_type" : { "type" : "string", "enum" : [ + "profile.update.bio", + "profile.update.profile_picture", + "profile.update.banner_picture", + "profile.update.screenname", + "profile.update.geo", + "profile.update.url", + "profile.update.verified_badge", + "news.new", + "follow.follow", + "follow.unfollow", "ProfileBioUpdate", "ProfilePictureUpdate", "ProfileBannerPictureUpdate", @@ -12037,7 +12435,9 @@ "ProfileGeoUpdate", "ProfileUrlUpdate", "ProfileVerifiedBadgeUpdate", - "ProfileHandleUpdate" + "NewsNew", + "FollowFollow", + "FollowUnfollow" ] }, "filter" : { @@ -12121,6 +12521,9 @@ "type" : "object", "description" : "An XAA subscription.", "properties" : { + "keyword" : { + "$ref" : "#/components/schemas/Keyword" + }, "user_id" : { "$ref" : "#/components/schemas/UserId" } @@ -12655,6 +13058,39 @@ } ] }, + "Connection" : { + "type" : "object", + "required" : [ + "connected_at", + "endpoint_name" + ], + "properties" : { + "client_ip" : { + "type" : "string", + "description" : "The IP address of the connected client." + }, + "connected_at" : { + "type" : "string", + "description" : "The timestamp when the connection was established.", + "format" : "date-time" + }, + "disconnect_reason" : { + "type" : "string", + "description" : "The reason for disconnection, if the connection is inactive.", + "example" : "operator_disconnect" + }, + "disconnected_at" : { + "type" : "string", + "description" : "The timestamp when the connection was disconnected, if applicable.", + "format" : "date-time" + }, + "endpoint_name" : { + "type" : "string", + "description" : "The name of the streaming endpoint.", + "example" : "sample_stream" + } + } + }, "ConnectionExceptionProblem" : { "description" : "A problem that indicates something is wrong with the connection.", "allOf" : [ @@ -13565,6 +14001,18 @@ } } }, + "FollowActivityResponsePayload" : { + "type" : "object", + "properties" : { + "source" : { + "$ref" : "#/components/schemas/User" + }, + "target" : { + "$ref" : "#/components/schemas/User" + } + }, + "additionalProperties" : false + }, "FoundMediaOrigin" : { "type" : "object", "required" : [ @@ -13851,6 +14299,36 @@ } } }, + "Get2ConnectionsResponse" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "array", + "minItems" : 1, + "items" : { + "$ref" : "#/components/schemas/Connection" + } + }, + "errors" : { + "type" : "array", + "minItems" : 1, + "items" : { + "$ref" : "#/components/schemas/Problem" + } + }, + "meta" : { + "type" : "object", + "properties" : { + "next_token" : { + "$ref" : "#/components/schemas/NextToken" + }, + "result_count" : { + "$ref" : "#/components/schemas/ResultCount" + } + } + } + } + }, "Get2DmConversationsIdDmEventsResponse" : { "type" : "object", "properties" : { @@ -14309,6 +14787,48 @@ } } }, + "Get2NewsIdResponse" : { + "type" : "object", + "properties" : { + "data" : { + "$ref" : "#/components/schemas/News" + }, + "errors" : { + "type" : "array", + "minItems" : 1, + "items" : { + "$ref" : "#/components/schemas/Problem" + } + } + } + }, + "Get2NewsSearchResponse" : { + "type" : "object", + "properties" : { + "data" : { + "type" : "array", + "minItems" : 1, + "items" : { + "$ref" : "#/components/schemas/News" + } + }, + "errors" : { + "type" : "array", + "minItems" : 1, + "items" : { + "$ref" : "#/components/schemas/Problem" + } + }, + "meta" : { + "type" : "object", + "properties" : { + "result_count" : { + "$ref" : "#/components/schemas/ResultCount" + } + } + } + } + }, "Get2NotesSearchNotesWrittenResponse" : { "type" : "object", "properties" : { @@ -15854,6 +16374,13 @@ "pattern" : "^[0-9]{1,19}$", "example" : "1372966999991541762" }, + "Keyword" : { + "type" : "string", + "description" : "A keyword to filter on.", + "minLength" : 1, + "maxLength" : 150, + "example" : "The President" + }, "KillAllConnectionsResponse" : { "type" : "object", "properties" : { @@ -16928,6 +17455,151 @@ "type" : "string", "description" : "The newest id in this response." }, + "News" : { + "type" : "object", + "description" : "An AI generated news story.", + "required" : [ + "rest_id" + ], + "properties" : { + "category" : { + "type" : "string", + "description" : "The news category." + }, + "cluster_posts_results" : { + "type" : "array", + "items" : { + "type" : "object", + "properties" : { + "post_id" : { + "$ref" : "#/components/schemas/TweetId" + } + } + } + }, + "contexts" : { + "type" : "object", + "properties" : { + "entities" : { + "type" : "object", + "properties" : { + "events" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "organizations" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "people" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "places" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "products" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + } + }, + "finance" : { + "type" : "object", + "properties" : { + "tickers" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + } + }, + "sports" : { + "type" : "object", + "properties" : { + "teams" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + } + }, + "topics" : { + "type" : "array", + "items" : { + "type" : "string" + } + } + } + }, + "disclaimer" : { + "type" : "string" + }, + "hook" : { + "type" : "string", + "description" : "The news hook." + }, + "keywords" : { + "type" : "array", + "items" : { + "type" : "string" + } + }, + "last_updated_at_ms" : { + "type" : "string", + "format" : "date-time", + "example" : "2025-7-14T04:35:55Z" + }, + "name" : { + "type" : "string", + "description" : "The headline." + }, + "rest_id" : { + "$ref" : "#/components/schemas/NewsId" + }, + "summary" : { + "type" : "string", + "description" : "The news summary." + } + } + }, + "NewsActivityResponsePayload" : { + "type" : "object", + "properties" : { + "category" : { + "type" : "string" + }, + "headline" : { + "type" : "string" + }, + "hook" : { + "type" : "string" + }, + "summary" : { + "type" : "string" + } + }, + "additionalProperties" : false + }, + "NewsId" : { + "type" : "string", + "description" : "Unique identifier of news story.", + "pattern" : "^[0-9]{1,19}$", + "example" : "2244994945" + }, "NextToken" : { "type" : "string", "description" : "The next token.", @@ -17679,6 +18351,18 @@ } } }, + "ProfileUpdateActivityResponsePayload" : { + "type" : "object", + "properties" : { + "after" : { + "type" : "string" + }, + "before" : { + "type" : "string" + } + }, + "additionalProperties" : false + }, "ReplayJobCreateResponse" : { "type" : "object", "description" : "Confirmation that the replay job request was accepted.", @@ -18980,6 +19664,19 @@ "$ref" : "#/components/schemas/UrlEntity" } }, + "suggested_source_links_with_counts" : { + "type" : "object", + "description" : "Suggested source links and the number of requests that included each link.", + "properties" : { + "count" : { + "type" : "integer", + "description" : "Number of note requests that included the source link." + }, + "url" : { + "$ref" : "#/components/schemas/UrlEntity" + } + } + }, "text" : { "$ref" : "#/components/schemas/TweetText" }, @@ -19211,6 +19908,10 @@ "in_reply_to_tweet_id" ], "properties" : { + "auto_populate_reply_metadata" : { + "type" : "boolean", + "description" : "If set to true, reply metadata will be automatically populated." + }, "exclude_reply_user_ids" : { "type" : "array", "description" : "A list of User Ids to be excluded from the reply Tweet.", @@ -20998,6 +21699,31 @@ } } } + }, + "WebhookReplayCreateRequest" : { + "type" : "object", + "required" : [ + "webhook_id", + "from_date", + "to_date" + ], + "properties" : { + "from_date" : { + "type" : "string", + "description" : "The oldest (starting) UTC timestamp (inclusive) from which events will be provided, in yyyymmddhhmm format.", + "pattern" : "^[0-9]{12}$", + "example" : "202504242000" + }, + "to_date" : { + "type" : "string", + "description" : "The oldest (starting) UTC timestamp (inclusive) from which events will be provided, in yyyymmddhhmm format.", + "pattern" : "^[0-9]{12}$", + "example" : "202504242000" + }, + "webhook_id" : { + "$ref" : "#/components/schemas/WebhookConfigId" + } + } } }, "parameters" : { @@ -21033,6 +21759,7 @@ "shares", "timestamp", "unfollows", + "unlikes", "url_clicks", "user_profile_clicks" ] @@ -21057,6 +21784,7 @@ "shares", "timestamp", "unfollows", + "unlikes", "url_clicks", "user_profile_clicks" ] @@ -21140,6 +21868,39 @@ "explode" : false, "style" : "form" }, + "ConnectionFieldsParameter" : { + "name" : "connection.fields", + "in" : "query", + "description" : "A comma separated list of Connection fields to display.", + "required" : false, + "schema" : { + "type" : "array", + "description" : "The fields available for a Connection object.", + "minItems" : 1, + "uniqueItems" : true, + "items" : { + "type" : "string", + "enum" : [ + "client_ip", + "connected_at", + "disconnect_reason", + "disconnected_at", + "endpoint_name", + "id" + ] + }, + "example" : [ + "client_ip", + "connected_at", + "disconnect_reason", + "disconnected_at", + "endpoint_name", + "id" + ] + }, + "explode" : false, + "style" : "form" + }, "DmConversationFieldsParameter" : { "name" : "dm_conversation.fields", "in" : "query", @@ -21514,6 +22275,47 @@ "explode" : false, "style" : "form" }, + "NewsFieldsParameter" : { + "name" : "news.fields", + "in" : "query", + "description" : "A comma separated list of News fields to display.", + "required" : false, + "schema" : { + "type" : "array", + "description" : "The fields available for a News object.", + "minItems" : 1, + "uniqueItems" : true, + "items" : { + "type" : "string", + "enum" : [ + "category", + "cluster_posts_results", + "contexts", + "disclaimer", + "hook", + "id", + "keywords", + "name", + "summary", + "updated_at" + ] + }, + "example" : [ + "category", + "cluster_posts_results", + "contexts", + "disclaimer", + "hook", + "id", + "keywords", + "name", + "summary", + "updated_at" + ] + }, + "explode" : false, + "style" : "form" + }, "NoteFieldsParameter" : { "name" : "note.fields", "in" : "query", @@ -22085,6 +22887,7 @@ "scopes", "source", "suggested_source_links", + "suggested_source_links_with_counts", "text", "withheld" ] @@ -22118,6 +22921,7 @@ "scopes", "source", "suggested_source_links", + "suggested_source_links_with_counts", "text", "withheld" ] diff --git a/xdk-gen/templates/typescript/client_class.j2 b/xdk-gen/templates/typescript/client_class.j2 index 0a18c7b8..91740953 100644 --- a/xdk-gen/templates/typescript/client_class.j2 +++ b/xdk-gen/templates/typescript/client_class.j2 @@ -5,7 +5,7 @@ * This module provides a client for interacting with the {{ tag.display_name }} endpoints of the X API. */ -import { Client, ApiResponse, RequestOptions } from '../client.js'; +import { Client, ApiResponse, RequestOptions, normalizeFields, transformKeysToSnake } from '../client.js'; import { Paginator, PostPaginator, @@ -117,9 +117,49 @@ export class {{ tag.class_name }}Client { {% if operation.request_body and operation.request_body.required %} * @param body {% if operation.request_body.content and operation.request_body.content["application/json"] and operation.request_body.content["application/json"].schema and operation.request_body.content["application/json"].schema.description %}{{ operation.request_body.content["application/json"].schema.description }}{% else %}Request body{% endif %} {% endif %} - * @returns {Promise<{% if operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %}>} Promise resolving to the API response + * @returns {Promise<{% if operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %}>} Promise resolving to the API response, or raw Response if requestOptions.raw is true */ - // Overload 1: Default behavior (unwrapped response) + // Overload 1: raw: true returns Response + {{ operation.method_name }}( + {% for param in operation.parameters | selectattr('location', 'equalto', 'path') %} + {% if param.variable_name %} + {{ param.variable_name }}: {% if param.schema and param.schema.type %}{{ param.schema.type | typescript_type }}{% else %}string{% endif %}, + {% endif %} + {% endfor %} + {% for param in operation.parameters | selectattr('required') | rejectattr('location', 'equalto', 'path') %} + {% if param.variable_name %} + {{ param.variable_name }}: {% if param.schema and param.schema.type %}{{ param.schema.type | typescript_type }}{% else %}any{% endif %}, + {% endif %} + {% endfor %} + {% if operation.request_body and operation.request_body.required %} + body: {{ operation.class_name }}Request, + {% endif %} + {% if operation.parameters | rejectattr('required') | rejectattr('location', 'equalto', 'path') | list | length > 0 or (operation.request_body and not operation.request_body.required) %} + options: {{ operation.class_name }}Options & { requestOptions: { raw: true } } + {% else %} + options: { requestOptions: { raw: true } } + {% endif %} + ): Promise; + // Overload 2: Default behavior returns parsed response + {{ operation.method_name }}( + {% for param in operation.parameters | selectattr('location', 'equalto', 'path') %} + {% if param.variable_name %} + {{ param.variable_name }}: {% if param.schema and param.schema.type %}{{ param.schema.type | typescript_type }}{% else %}string{% endif %}, + {% endif %} + {% endfor %} + {% for param in operation.parameters | selectattr('required') | rejectattr('location', 'equalto', 'path') %} + {% if param.variable_name %} + {{ param.variable_name }}: {% if param.schema and param.schema.type %}{{ param.schema.type | typescript_type }}{% else %}any{% endif %}, + {% endif %} + {% endfor %} + {% if operation.request_body and operation.request_body.required %} + body: {{ operation.class_name }}Request, + {% endif %} + {% if operation.parameters | rejectattr('required') | rejectattr('location', 'equalto', 'path') | list | length > 0 or (operation.request_body and not operation.request_body.required) %} + options?: {{ operation.class_name }}Options + {% endif %} + ): Promise<{% if operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %}>; + // Implementation async {{ operation.method_name }}( {# Path parameters are always required - use location field #} {% for param in operation.parameters | selectattr('location', 'equalto', 'path') %} @@ -141,7 +181,7 @@ export class {{ tag.class_name }}Client { {% if operation.parameters | rejectattr('required') | rejectattr('location', 'equalto', 'path') | list | length > 0 or (operation.request_body and not operation.request_body.required) %} options: {{ operation.class_name }}Options = {} {% endif %} - ): Promise<{% if operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %}> { + ): Promise<{% if operation.responses and "200" in operation.responses or operation.responses and "201" in operation.responses %}{{ operation.class_name }}Response{% else %}any{% endif %} | Response> { // Normalize options to handle both camelCase and original API parameter names {% if operation.parameters | rejectattr('required') | rejectattr('location', 'equalto', 'path') | list | length > 0 or (operation.request_body and not operation.request_body.required) %} {% if operation.parameters | rejectattr('required') | rejectattr('location', 'equalto', 'path') | list | length > 0 %} @@ -189,7 +229,11 @@ export class {{ tag.class_name }}Client { {% if param.required %} if ({{ var_name }} !== undefined{% if param.schema and param.schema.type == 'array' %} && {{ var_name }}.length > 0{% endif %}) { {% if param.schema and param.schema.type == 'array' %} + {% if '.fields' in param.original_name or '_fields' in param.original_name %} + params.append('{{ param.original_name }}', normalizeFields({{ var_name }}).join(',')); + {% else %} params.append('{{ param.original_name }}', {{ var_name }}.join(',')); + {% endif %} {% else %} params.append('{{ param.original_name }}', String({{ var_name }})); {% endif %} @@ -197,7 +241,11 @@ export class {{ tag.class_name }}Client { {% else %} if ({{ var_name }} !== undefined{% if param.schema and param.schema.type == 'array' %} && {{ var_name }}.length > 0{% endif %}) { {% if param.schema and param.schema.type == 'array' %} + {% if '.fields' in param.original_name or '_fields' in param.original_name %} + params.append('{{ param.original_name }}', normalizeFields({{ var_name }}).join(',')); + {% else %} params.append('{{ param.original_name }}', {{ var_name }}.join(',')); + {% endif %} {% else %} params.append('{{ param.original_name }}', String({{ var_name }})); {% endif %} @@ -209,9 +257,9 @@ export class {{ tag.class_name }}Client { // Prepare request options const finalRequestOptions: RequestOptions = { {% if operation.request_body and operation.request_body.required %} - body: JSON.stringify(body || {}), + body: JSON.stringify(transformKeysToSnake(body || {})), {% elif operation.request_body and not operation.request_body.required %} - body: body ? JSON.stringify(body) : undefined, + body: body ? JSON.stringify(transformKeysToSnake(body)) : undefined, {% endif %} {% if operation.security %} // Pass security requirements for smart auth selection @@ -327,7 +375,11 @@ export class {{ tag.class_name }}Client { {% if param.required %} if ({{ var_name }} !== undefined{% if param.schema and param.schema.type == 'array' %} && {{ var_name }}.length > 0{% endif %}) { {% if param.schema and param.schema.type == 'array' %} + {% if '.fields' in param.original_name or '_fields' in param.original_name %} + params.append('{{ param.original_name }}', normalizeFields({{ var_name }}).join(',')); + {% else %} params.append('{{ param.original_name }}', {{ var_name }}.join(',')); + {% endif %} {% else %} params.append('{{ param.original_name }}', String({{ var_name }})); {% endif %} @@ -335,7 +387,11 @@ export class {{ tag.class_name }}Client { {% else %} if ({{ var_name }} !== undefined{% if param.schema and param.schema.type == 'array' %} && {{ var_name }}.length > 0{% endif %}) { {% if param.schema and param.schema.type == 'array' %} + {% if '.fields' in param.original_name or '_fields' in param.original_name %} + params.append('{{ param.original_name }}', normalizeFields({{ var_name }}).join(',')); + {% else %} params.append('{{ param.original_name }}', {{ var_name }}.join(',')); + {% endif %} {% else %} params.append('{{ param.original_name }}', String({{ var_name }})); {% endif %} diff --git a/xdk-gen/templates/typescript/crypto_utils.j2 b/xdk-gen/templates/typescript/crypto_utils.j2 index cd957330..cec54afe 100644 --- a/xdk-gen/templates/typescript/crypto_utils.j2 +++ b/xdk-gen/templates/typescript/crypto_utils.j2 @@ -1,10 +1,15 @@ /** * Environment-agnostic cryptographic utilities for the X API SDK. - * Provides HMAC-SHA1 implementation that works in both Node.js and browser environments. + * Provides HMAC-SHA1 implementation that works in Node.js, browser, and React Native environments. */ +// Environment detection +const isReactNative = typeof navigator !== 'undefined' && navigator.product === 'ReactNative'; +const isNode = !isReactNative && typeof process !== 'undefined' && process.versions && process.versions.node; +const isBrowser = !isReactNative && !isNode && typeof window !== 'undefined'; + /** - * HMAC-SHA1 implementation that works in both Node.js and browser environments + * HMAC-SHA1 implementation that works in Node.js, browser, and React Native environments */ export class CryptoUtils { /** @@ -14,8 +19,23 @@ export class CryptoUtils { * @returns Base64 encoded signature */ static async hmacSha1(key: string, message: string): Promise { + // For React Native, prefer Web Crypto API (requires react-native-get-random-values polyfill) + // or fall back to polyfill + if (isReactNative) { + // Try Web Crypto API first (available if user installed crypto polyfills) + if (typeof crypto !== 'undefined' && crypto.subtle) { + try { + return await this._webCryptoHmacSha1(key, message); + } catch (error) { + // Fall back to polyfill + } + } + // Use polyfill for React Native + return this._polyfillHmacSha1(key, message); + } + // Try to use native Node.js crypto first - if (typeof process !== 'undefined' && process.versions && process.versions.node) { + if (isNode) { try { return await this._nodeHmacSha1(key, message); } catch (error) { @@ -24,7 +44,7 @@ export class CryptoUtils { } } - // Try Web Crypto API (modern browsers) + // Try Web Crypto API (modern browsers and some environments) if (typeof crypto !== 'undefined' && crypto.subtle) { try { return await this._webCryptoHmacSha1(key, message); @@ -75,16 +95,151 @@ export class CryptoUtils { /** * Polyfill HMAC-SHA1 implementation using pure JavaScript - * This is a fallback that works everywhere but is slower + * This is a fallback that works everywhere including React Native */ private static _polyfillHmacSha1(key: string, message: string): string { - // For now, throw an error to indicate that proper crypto is needed - // This will help identify when the fallback is being used - throw new Error('HMAC-SHA1 polyfill not implemented. Please ensure Node.js crypto or Web Crypto API is available.'); + // Pure JavaScript HMAC-SHA1 implementation + const sha1 = this._sha1; + const blockSize = 64; + + // Convert key to bytes + let keyBytes = this._stringToBytes(key); + + // If key is longer than block size, hash it + if (keyBytes.length > blockSize) { + keyBytes = sha1(keyBytes); + } - // In a real implementation, you would use a library like crypto-js: - // import CryptoJS from 'crypto-js'; - // return CryptoJS.HmacSHA1(message, key).toString(CryptoJS.enc.Base64); + // Pad key to block size + while (keyBytes.length < blockSize) { + keyBytes.push(0); + } + + // Create inner and outer padding + const innerPad: number[] = []; + const outerPad: number[] = []; + for (let i = 0; i < blockSize; i++) { + innerPad.push(keyBytes[i] ^ 0x36); + outerPad.push(keyBytes[i] ^ 0x5c); + } + + // Inner hash: SHA1(innerPad + message) + const messageBytes = this._stringToBytes(message); + const innerHash = sha1(innerPad.concat(messageBytes)); + + // Outer hash: SHA1(outerPad + innerHash) + const hmacBytes = sha1(outerPad.concat(innerHash)); + + // Convert to base64 + let binary = ''; + for (let i = 0; i < hmacBytes.length; i++) { + binary += String.fromCharCode(hmacBytes[i]); + } + return btoa(binary); + } + + /** + * Pure JavaScript SHA-1 implementation + */ + private static _sha1(message: number[]): number[] { + // Pre-processing + const msgLen = message.length; + const bitLen = msgLen * 8; + + // Append bit '1' to message + message.push(0x80); + + // Append zeros until message length ≡ 448 (mod 512) + while ((message.length % 64) !== 56) { + message.push(0); + } + + // Append original length in bits as 64-bit big-endian + for (let i = 56; i >= 0; i -= 8) { + message.push((bitLen >>> i) & 0xff); + } + + // Initialize hash values + let h0 = 0x67452301; + let h1 = 0xEFCDAB89; + let h2 = 0x98BADCFE; + let h3 = 0x10325476; + let h4 = 0xC3D2E1F0; + + // Process each 512-bit chunk + for (let i = 0; i < message.length; i += 64) { + const w: number[] = []; + + // Break chunk into sixteen 32-bit big-endian words + for (let j = 0; j < 16; j++) { + w[j] = (message[i + j * 4] << 24) | + (message[i + j * 4 + 1] << 16) | + (message[i + j * 4 + 2] << 8) | + (message[i + j * 4 + 3]); + } + + // Extend sixteen 32-bit words into eighty 32-bit words + for (let j = 16; j < 80; j++) { + const n = w[j - 3] ^ w[j - 8] ^ w[j - 14] ^ w[j - 16]; + w[j] = (n << 1) | (n >>> 31); + } + + // Initialize working variables + let a = h0, b = h1, c = h2, d = h3, e = h4; + + // Main loop + for (let j = 0; j < 80; j++) { + let f: number, k: number; + if (j < 20) { + f = (b & c) | ((~b) & d); + k = 0x5A827999; + } else if (j < 40) { + f = b ^ c ^ d; + k = 0x6ED9EBA1; + } else if (j < 60) { + f = (b & c) | (b & d) | (c & d); + k = 0x8F1BBCDC; + } else { + f = b ^ c ^ d; + k = 0xCA62C1D6; + } + + const temp = (((a << 5) | (a >>> 27)) + f + e + k + w[j]) >>> 0; + e = d; + d = c; + c = ((b << 30) | (b >>> 2)) >>> 0; + b = a; + a = temp; + } + + // Add chunk's hash to result + h0 = (h0 + a) >>> 0; + h1 = (h1 + b) >>> 0; + h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; + } + + // Produce the final hash value (big-endian) + const hash: number[] = []; + for (const h of [h0, h1, h2, h3, h4]) { + hash.push((h >>> 24) & 0xff); + hash.push((h >>> 16) & 0xff); + hash.push((h >>> 8) & 0xff); + hash.push(h & 0xff); + } + return hash; + } + + /** + * Convert string to byte array + */ + private static _stringToBytes(str: string): number[] { + const bytes: number[] = []; + for (let i = 0; i < str.length; i++) { + bytes.push(str.charCodeAt(i) & 0xff); + } + return bytes; } /** @@ -170,8 +325,20 @@ export class CryptoUtils { * @returns Base64url encoded SHA256 hash of the code verifier */ static async generateCodeChallenge(codeVerifier: string): Promise { + // For React Native, prefer Web Crypto API or fall back to polyfill + if (isReactNative) { + if (typeof crypto !== 'undefined' && crypto.subtle) { + try { + return await this._webCryptoSha256(codeVerifier); + } catch (error) { + // Fall back to polyfill + } + } + return this._polyfillSha256(codeVerifier); + } + // Try to use native Node.js crypto first - if (typeof process !== 'undefined' && process.versions && process.versions.node) { + if (isNode) { try { return await this._nodeSha256(codeVerifier); } catch (error) { @@ -214,15 +381,124 @@ export class CryptoUtils { /** * Polyfill SHA256 implementation for PKCE - * This is a fallback that works everywhere but is slower + * Pure JavaScript implementation that works in React Native and other environments */ private static _polyfillSha256(message: string): string { - // For now, throw an error to indicate that proper crypto is needed - throw new Error('SHA256 polyfill not implemented. Please ensure Node.js crypto or Web Crypto API is available.'); + // Pure JavaScript SHA-256 implementation + const msgBytes = this._stringToBytes(message); + const hashBytes = this._sha256(msgBytes); + return this._base64UrlEncode(new Uint8Array(hashBytes)); + } + + /** + * Pure JavaScript SHA-256 implementation + */ + private static _sha256(message: number[]): number[] { + // SHA-256 constants + const K = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 + ]; + + // Pre-processing + const msgLen = message.length; + const bitLen = msgLen * 8; + + // Append bit '1' to message + message.push(0x80); + + // Append zeros until message length ≡ 448 (mod 512) + while ((message.length % 64) !== 56) { + message.push(0); + } + + // Append original length in bits as 64-bit big-endian + // Note: JavaScript bitwise operations work on 32 bits, so we handle this carefully + message.push(0, 0, 0, 0); // High 32 bits (assuming message < 2^32 bits) + message.push((bitLen >>> 24) & 0xff); + message.push((bitLen >>> 16) & 0xff); + message.push((bitLen >>> 8) & 0xff); + message.push(bitLen & 0xff); + + // Initialize hash values + let h0 = 0x6a09e667; + let h1 = 0xbb67ae85; + let h2 = 0x3c6ef372; + let h3 = 0xa54ff53a; + let h4 = 0x510e527f; + let h5 = 0x9b05688c; + let h6 = 0x1f83d9ab; + let h7 = 0x5be0cd19; - // In a real implementation, you would use a library like crypto-js: - // import CryptoJS from 'crypto-js'; - // return CryptoJS.SHA256(message).toString(CryptoJS.enc.Base64url); + // Helper functions + const rotr = (n: number, x: number) => ((x >>> n) | (x << (32 - n))) >>> 0; + const ch = (x: number, y: number, z: number) => ((x & y) ^ ((~x) & z)) >>> 0; + const maj = (x: number, y: number, z: number) => ((x & y) ^ (x & z) ^ (y & z)) >>> 0; + const sigma0 = (x: number) => (rotr(2, x) ^ rotr(13, x) ^ rotr(22, x)) >>> 0; + const sigma1 = (x: number) => (rotr(6, x) ^ rotr(11, x) ^ rotr(25, x)) >>> 0; + const gamma0 = (x: number) => (rotr(7, x) ^ rotr(18, x) ^ (x >>> 3)) >>> 0; + const gamma1 = (x: number) => (rotr(17, x) ^ rotr(19, x) ^ (x >>> 10)) >>> 0; + + // Process each 512-bit chunk + for (let i = 0; i < message.length; i += 64) { + const w: number[] = []; + + // Break chunk into sixteen 32-bit big-endian words + for (let j = 0; j < 16; j++) { + w[j] = ((message[i + j * 4] << 24) | + (message[i + j * 4 + 1] << 16) | + (message[i + j * 4 + 2] << 8) | + (message[i + j * 4 + 3])) >>> 0; + } + + // Extend sixteen 32-bit words into sixty-four 32-bit words + for (let j = 16; j < 64; j++) { + w[j] = (gamma1(w[j - 2]) + w[j - 7] + gamma0(w[j - 15]) + w[j - 16]) >>> 0; + } + + // Initialize working variables + let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7; + + // Main loop + for (let j = 0; j < 64; j++) { + const t1 = (h + sigma1(e) + ch(e, f, g) + K[j] + w[j]) >>> 0; + const t2 = (sigma0(a) + maj(a, b, c)) >>> 0; + h = g; + g = f; + f = e; + e = (d + t1) >>> 0; + d = c; + c = b; + b = a; + a = (t1 + t2) >>> 0; + } + + // Add chunk's hash to result + h0 = (h0 + a) >>> 0; + h1 = (h1 + b) >>> 0; + h2 = (h2 + c) >>> 0; + h3 = (h3 + d) >>> 0; + h4 = (h4 + e) >>> 0; + h5 = (h5 + f) >>> 0; + h6 = (h6 + g) >>> 0; + h7 = (h7 + h) >>> 0; + } + + // Produce the final hash value (big-endian) + const hash: number[] = []; + for (const hVal of [h0, h1, h2, h3, h4, h5, h6, h7]) { + hash.push((hVal >>> 24) & 0xff); + hash.push((hVal >>> 16) & 0xff); + hash.push((hVal >>> 8) & 0xff); + hash.push(hVal & 0xff); + } + return hash; } /** diff --git a/xdk-gen/templates/typescript/http_client.j2 b/xdk-gen/templates/typescript/http_client.j2 index e44748a0..693adf84 100644 --- a/xdk-gen/templates/typescript/http_client.j2 +++ b/xdk-gen/templates/typescript/http_client.j2 @@ -1,13 +1,14 @@ /** * Environment-aware HTTP client for the X API SDK. * - * This module provides a universal HTTP client that works in both Node.js and browser environments - * without requiring manual polyfills. + * This module provides a universal HTTP client that works in Node.js, browser, + * and React Native environments without requiring manual polyfills. */ // Environment detection -const isNode = typeof process !== 'undefined' && process.versions && process.versions.node; -const isBrowser = typeof window !== 'undefined' && typeof window.fetch === 'function'; +const isReactNative = typeof navigator !== 'undefined' && navigator.product === 'ReactNative'; +const isNode = !isReactNative && typeof process !== 'undefined' && process.versions && process.versions.node; +const isBrowser = !isReactNative && !isNode && typeof window !== 'undefined' && typeof window.fetch === 'function'; // Type definitions export interface RequestOptions { @@ -41,16 +42,24 @@ export class HttpClient { } private initializeEnvironment(): void { - if (isNode) { + if (isReactNative) { + // React Native environment - use native fetch API + // React Native has built-in fetch support + // Bind to globalThis to preserve context (required for Cloudflare Workers and similar environments) + this.fetch = globalThis.fetch.bind(globalThis); + this.HeadersClass = globalThis.Headers; + } else if (isNode) { // Node.js environment - set up polyfills synchronously this.initializeNodeEnvironment(); } else if (isBrowser) { // Browser environment - use native APIs - this.fetch = globalThis.fetch; + // Bind to globalThis to preserve context (required for Cloudflare Workers and similar environments) + this.fetch = globalThis.fetch.bind(globalThis); this.HeadersClass = globalThis.Headers; } else { - // Fallback for other environments (Deno, etc.) - this.fetch = globalThis.fetch; + // Fallback for other environments (Deno, Cloudflare Workers, etc.) + // Bind to globalThis to preserve context (required for Cloudflare Workers and similar environments) + this.fetch = globalThis.fetch.bind(globalThis); this.HeadersClass = globalThis.Headers; } } @@ -58,7 +67,8 @@ export class HttpClient { private initializeNodeEnvironment(): void { // Check if native fetch is available (Node.js 18+) if (typeof globalThis.fetch === 'function' && typeof globalThis.Headers === 'function') { - this.fetch = globalThis.fetch; + // Bind to globalThis to preserve context (required for Cloudflare Workers and similar environments) + this.fetch = globalThis.fetch.bind(globalThis); this.HeadersClass = globalThis.Headers; return; } @@ -94,7 +104,8 @@ export class HttpClient { // Convert body to string if it's a Buffer or ArrayBuffer let body = options.body; if (body && typeof body !== 'string') { - if (Buffer.isBuffer(body)) { + // Check for Node.js Buffer (only in Node.js environment) + if (isNode && typeof Buffer !== 'undefined' && Buffer.isBuffer && Buffer.isBuffer(body)) { body = body.toString(); } else if (body instanceof ArrayBuffer) { body = new TextDecoder().decode(body); diff --git a/xdk-gen/templates/typescript/main_client.j2 b/xdk-gen/templates/typescript/main_client.j2 index db1ef1d1..9fbb4b49 100644 --- a/xdk-gen/templates/typescript/main_client.j2 +++ b/xdk-gen/templates/typescript/main_client.j2 @@ -100,15 +100,102 @@ export interface ApiResponse { */ export interface PaginationMeta { /** Next page token */ - next_token?: string; + nextToken?: string; /** Previous page token */ - previous_token?: string; + previousToken?: string; /** Total count */ - total_count?: number; + totalCount?: number; /** Result count */ - result_count?: number; + resultCount?: number; } +/** + * Convert a snake_case string to camelCase + * @param str The snake_case string to convert + * @returns The camelCase string + */ +function snakeToCamel(str: string): string { + return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +/** + * Convert a camelCase string to snake_case + * @param str The camelCase string to convert + * @returns The snake_case string + */ +export function camelToSnake(str: string): string { + return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`); +} + +/** + * Convert an array of field names from camelCase to snake_case + * Handles both camelCase and already snake_case values + * @param fields Array of field names + * @returns Array with snake_case field names + */ +export function normalizeFields(fields: string[]): string[] { + return fields.map(field => { + // If already snake_case (contains underscore), keep as-is + if (field.includes('_')) { + return field; + } + // Convert camelCase to snake_case + return camelToSnake(field); + }); +} + +/** + * Recursively transform all keys in an object from snake_case to camelCase + * @param obj The object to transform + * @returns A new object with camelCase keys + */ +function transformKeys(obj: unknown): T { + if (obj === null || obj === undefined) { + return obj as T; + } + + if (Array.isArray(obj)) { + return obj.map(item => transformKeys(item)) as T; + } + + if (typeof obj === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + const camelKey = snakeToCamel(key); + result[camelKey] = transformKeys(value); + } + return result as T; + } + + return obj as T; +} + +/** + * Recursively transform all keys in an object from camelCase to snake_case + * Used for request bodies to convert TypeScript conventions to API format + * @param obj The object to transform + * @returns A new object with snake_case keys + */ +export function transformKeysToSnake(obj: unknown): T { + if (obj === null || obj === undefined) { + return obj as T; + } + + if (Array.isArray(obj)) { + return obj.map(item => transformKeysToSnake(item)) as T; + } + + if (typeof obj === 'object') { + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + const snakeKey = camelToSnake(key); + result[snakeKey] = transformKeysToSnake(value); + } + return result as T; + } + + return obj as T; +} /** * Main client class for the X API @@ -331,7 +418,9 @@ export class Client { let data: T; const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { - data = await response.json(); + const rawData = await response.json(); + // Transform snake_case keys to camelCase to match TypeScript conventions + data = transformKeys(rawData); } else { data = await response.text() as T; } diff --git a/xdk-gen/templates/typescript/oauth2_auth.j2 b/xdk-gen/templates/typescript/oauth2_auth.j2 index 6375c2dd..81e5b116 100644 --- a/xdk-gen/templates/typescript/oauth2_auth.j2 +++ b/xdk-gen/templates/typescript/oauth2_auth.j2 @@ -40,6 +40,7 @@ export interface OAuth2Token { export class OAuth2 { private config: OAuth2Config; private token?: OAuth2Token; + private tokenExpiresAt?: number; private codeVerifier?: string; private codeChallenge?: string; @@ -128,6 +129,64 @@ export class OAuth2 { return this.token; } + /** + * Refresh an access token using a refresh token + * @param refreshToken The refresh token to use (uses stored token if not provided) + * @returns Promise with new OAuth2 token + */ + async refreshToken(refreshToken?: string): Promise { + const tokenToUse = refreshToken || this.token?.refresh_token; + + if (!tokenToUse) { + throw new Error('No refresh token available. Please provide a refresh token or complete the OAuth2 flow first.'); + } + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: tokenToUse + }); + + // Prepare headers + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded' + }; + + // Add Basic Auth header if client secret is provided + if (this.config.clientSecret) { + const credentials = this._base64Encode(`${this.config.clientId}:${this.config.clientSecret}`); + headers['Authorization'] = `Basic ${credentials}`; + } else { + // Only add client_id to body if no client_secret (public client) + params.append('client_id', this.config.clientId); + } + + const response = await fetch('https://api.x.com/2/oauth2/token', { + method: 'POST', + headers, + body: params.toString() + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => response.text()); + throw new Error(`Failed to refresh token: ${response.status}, body: ${JSON.stringify(errorData)}`); + } + + const data = await response.json(); + this.token = { + access_token: data.access_token, + token_type: data.token_type, + expires_in: data.expires_in, + refresh_token: data.refresh_token, + scope: data.scope + }; + // Track when the token expires + if (data.expires_in) { + this.tokenExpiresAt = Date.now() + (data.expires_in * 1000); + } + + return this.token; + } + /** * Get the current token * @returns Current OAuth2 token if available @@ -136,6 +195,37 @@ export class OAuth2 { return this.token; } + /** + * Set a token directly (useful for restoring from storage) + * @param token The OAuth2 token to set + * @param expiresAt Optional timestamp (ms) when the token expires + */ + setToken(token: OAuth2Token, expiresAt?: number): void { + this.token = token; + if (expiresAt) { + this.tokenExpiresAt = expiresAt; + } else if (token.expires_in) { + // If no explicit expiresAt but token has expires_in, calculate from now + this.tokenExpiresAt = Date.now() + (token.expires_in * 1000); + } + } + + /** + * Check if the current token is expired or about to expire + * @param bufferSeconds Number of seconds before expiry to consider as "expiring" (default: 60) + * @returns True if token is expired or missing + */ + isTokenExpired(bufferSeconds: number = 60): boolean { + if (!this.token) { + return true; + } + // If we don't have timing info, assume not expired + if (!this.tokenExpiresAt) { + return false; + } + return Date.now() >= this.tokenExpiresAt - (bufferSeconds * 1000); + } + /** * Get the current code verifier (for PKCE) * @returns Current code verifier if available diff --git a/xdk-gen/templates/typescript/package_json.j2 b/xdk-gen/templates/typescript/package_json.j2 index 6a375767..7fa8c036 100644 --- a/xdk-gen/templates/typescript/package_json.j2 +++ b/xdk-gen/templates/typescript/package_json.j2 @@ -53,7 +53,10 @@ "bearer-token", "crypto", "hmac-sha1", - "pkce" + "pkce", + "react-native", + "browser", + "nodejs" ], "author": "X API SDK Team", "license": "MIT", @@ -71,10 +74,15 @@ "engines": { "node": ">=16.14" }, - "dependencies": { + "peerDependencies": { "node-fetch": "^3.3.0" }, - "devDependencies": { + "peerDependenciesMeta": { + "node-fetch": { + "optional": true + } + }, + "devDependencies": { "@types/node": "^20.0.0", "@types/jest": "^29.5.0", "typescript": "^5.0.0", diff --git a/xdk-gen/templates/typescript/paginator.j2 b/xdk-gen/templates/typescript/paginator.j2 index 5f597eef..07f10a96 100644 --- a/xdk-gen/templates/typescript/paginator.j2 +++ b/xdk-gen/templates/typescript/paginator.j2 @@ -20,11 +20,11 @@ export interface PaginatedResponse { /** Pagination metadata */ meta?: { /** Number of results in the current page */ - result_count?: number; + resultCount?: number; /** Token for fetching the next page */ - next_token?: string; + nextToken?: string; /** Token for fetching the previous page */ - previous_token?: string; + previousToken?: string; }; /** Additional included objects (users, tweets, etc.) */ includes?: Record; @@ -57,7 +57,7 @@ export interface PaginatedResponse { * const followers = await client.users.getFollowers('783214'); * await followers.fetchNext(); * console.log(followers.items.length); // Number of followers - * console.log(followers.meta.next_token); // Next page token + * console.log(followers.meta.nextToken); // Next page token * * // Check status * if (!followers.done) { @@ -160,7 +160,7 @@ export class Paginator implements AsyncIterable { // Update tokens this.previousToken = this.currentToken; - this.currentToken = response.meta?.next_token; + this.currentToken = response.meta?.nextToken; // Update state this.hasMore = !!this.currentToken; @@ -229,7 +229,7 @@ export class Paginator implements AsyncIterable { // Update tokens this.currentToken = this.previousToken; - this.previousToken = response.meta?.previous_token; + this.previousToken = response.meta?.previousToken; // Update state this.hasMore = !!this.currentToken; diff --git a/xdk-gen/templates/typescript/process_for_mintlify.j2 b/xdk-gen/templates/typescript/process_for_mintlify.j2 index 1abd3634..d0caa4a3 100644 --- a/xdk-gen/templates/typescript/process_for_mintlify.j2 +++ b/xdk-gen/templates/typescript/process_for_mintlify.j2 @@ -921,7 +921,7 @@ while (!followers.done) { const userCount: number = followers.users.length; // all fetched users const firstUser: Schemas.User | undefined = followers.users[0]; -const nextToken: string | undefined = followers.meta?.next_token; +const nextToken: string | undefined = followers.meta?.nextToken; \`\`\` \`\`\`javascript manual.js theme={null} diff --git a/xdk-gen/templates/typescript/readme.j2 b/xdk-gen/templates/typescript/readme.j2 index 725f8b0f..5e48486f 100644 --- a/xdk-gen/templates/typescript/readme.j2 +++ b/xdk-gen/templates/typescript/readme.j2 @@ -9,6 +9,7 @@ A comprehensive TypeScript SDK for the X API (formerly Twitter API) with advance - **📡 Streaming**: Event-driven streaming with automatic reconnection - **📚 Type Safety**: Complete TypeScript definitions for all endpoints and parameters - **🎯 Full X API Support**: Users, Posts, Lists, Bookmarks, Communities, and more +- **📱 Multi-Platform**: Works in Node.js, browsers, and React Native ## Install @@ -39,6 +40,22 @@ The SDK is written in TypeScript and includes full type definitions. No addition - Node.js 16+ - TypeScript 4.5+ (if using TypeScript) +### React Native Support + +The SDK fully supports React Native out of the box. No additional polyfills or packages are required - just install the SDK and start using it: + +```javascript +import { Client } from '@xdevplatform/xdk'; + +const client = new Client({ bearerToken: 'your-token' }); +``` + +The SDK uses pure JavaScript crypto implementations that work in all environments, and React Native's built-in `fetch` for HTTP requests. + +### Browser Support + +The SDK works in modern browsers (Chrome, Firefox, Safari, Edge) out of the box using the Web Crypto API and native `fetch`. + ## Quick Start ```typescript @@ -230,7 +247,7 @@ while (!followers.done) { const userCount: number = followers.users.length; // all fetched users const firstUser: Schemas.User | undefined = followers.users[0]; -const nextToken: string | undefined = followers.meta?.next_token; +const nextToken: string | undefined = followers.meta?.nextToken; ``` ### Async iteration From b5fcb10d8a8f9df390d944f94c7feb915ca22ac9 Mon Sep 17 00:00:00 2001 From: Taylor Caldwell Date: Wed, 21 Jan 2026 15:00:02 -0800 Subject: [PATCH 2/2] format --- xdk-openapi/src/reference.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/xdk-openapi/src/reference.rs b/xdk-openapi/src/reference.rs index 6c9bf9cc..2aff2111 100644 --- a/xdk-openapi/src/reference.rs +++ b/xdk-openapi/src/reference.rs @@ -79,13 +79,13 @@ impl Serialize for RefOrValue { // First serialize the resolved value to a serde_json::Value let resolved_json = serde_json::to_value(resolved.as_ref()) .map_err(serde::ser::Error::custom)?; - + if let serde_json::Value::Object(mut obj) = resolved_json { // Add the $ref path to the object so templates can still extract type names obj.insert("$ref".to_string(), serde_json::Value::String(path.clone())); return obj.serialize(serializer); } - + // If not an object, just serialize the resolved value resolved.serialize(serializer) } else {