
Xurl
- 514 installs
- 1.3k repo stars
- Updated July 21, 2026
- xdevplatform/xurl
Helps with ai & agent building tasks.
About
xurl is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- xurl
- AI & Agent Building
- AI-coding skill
Xurl by the numbers
- 514 all-time installs (skills.sh)
- +8 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #1,746 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/xdevplatform/xurl --skill xurlAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 514 |
|---|---|
| repo stars | ★ 1.3k |
| Last updated | July 21, 2026 |
| Repository | xdevplatform/xurl ↗ |
What it does
Helps with ai & agent building tasks.
Files
xurl — Agent Skill Reference
xurl is a CLI tool for the X API. It supports both shortcut commands (human/agent‑friendly one‑liners) and raw curl‑style access to any v2 endpoint. All commands return JSON to stdout.
---
Prerequisites
This skill requires the xurl CLI utility: <https://github.com/xdevplatform/xurl>.
Before using any command you must be authenticated. Run xurl auth status to check.
Secret Safety (Mandatory)
- Never read, print, parse, summarize, upload, or send
~/.xurl(or copies of it) to the LLM context. - Never ask the user to paste credentials/tokens into chat.
- The user must fill
~/.xurlwith required secrets manually on their own machine. - Do not recommend or execute auth commands with inline secrets in agent/LLM sessions.
- Warn that using CLI secret options in agent sessions can leak credentials (prompt/context, logs, shell history).
- Never use
--verbose/-vin agent/LLM sessions; it can expose sensitive headers/tokens in output. - Sensitive flags that must never be used in agent commands:
--bearer-token,--consumer-key,--consumer-secret,--access-token,--token-secret,--client-id,--client-secret. - To verify whether at least one app with credentials is already registered, run:
xurl auth status.
Register an app (recommended)
App credential registration must be done manually by the user outside the agent/LLM session. After credentials are registered, authenticate against the app that holds those credentials:
xurl auth oauth2 --app APP_NAMEYou can also run xurl auth default APP_NAME first and then use xurl auth oauth2.
For multiple pre-configured apps, switch between them:
xurl auth default prod-app # set default app
xurl auth default prod-app alice # set default app + user
xurl --app dev-app /2/users/me # one-off override
xurl auth apps redirect-uri get prod-app
xurl auth apps redirect-uri set prod-app http://localhost:8080/callbackOther auth methods
Examples with inline secret flags are intentionally omitted. If OAuth1 or app-only auth is needed, the user must run those commands manually outside agent/LLM context.
Tokens are persisted to ~/.xurl in YAML format. Each app has its own isolated tokens and may also store a redirect_uri. REDIRECT_URI in the environment still takes precedence over the stored app value. Do not read this file through the agent/LLM. Once authenticated, every command below will auto‑attach the right Authorization header.
---
Quick Reference
| Action | Command |
|---|---|
| Post | xurl post "Hello world!" |
| Reply | xurl reply POST_ID "Nice post!" |
| Quote | xurl quote POST_ID "My take" |
| Delete a post | xurl delete POST_ID |
| Read a post | xurl read POST_ID |
| Search posts | xurl search "QUERY" -n 10 |
| Who am I | xurl whoami |
| Look up a user | xurl user @handle |
| Home timeline | xurl timeline -n 20 |
| Mentions | xurl mentions -n 10 |
| Like | xurl like POST_ID |
| Unlike | xurl unlike POST_ID |
| Repost | xurl repost POST_ID |
| Undo repost | xurl unrepost POST_ID |
| Bookmark | xurl bookmark POST_ID |
| Remove bookmark | xurl unbookmark POST_ID |
| List bookmarks | xurl bookmarks -n 10 |
| List likes | xurl likes -n 10 |
| Follow | xurl follow @handle |
| Unfollow | xurl unfollow @handle |
| List following | xurl following -n 20 |
| List followers | xurl followers -n 20 |
| Block | xurl block @handle |
| Unblock | xurl unblock @handle |
| Mute | xurl mute @handle |
| Unmute | xurl unmute @handle |
| Send DM | xurl dm @handle "message" |
| List DMs | xurl dms -n 10 |
| Upload media | xurl media upload path/to/file.mp4 |
| Media status | xurl media status MEDIA_ID |
| App Management | |
| Register app | Manual, outside agent (do not pass secrets via agent) |
| List apps | xurl auth apps list |
| Update app config | Manual, outside agent (do not pass secrets via agent) |
| View app redirect URI | xurl auth apps redirect-uri get [NAME] |
| Set app redirect URI | xurl auth apps redirect-uri set NAME URI |
| Remove app | xurl auth apps remove NAME |
| Set default (interactive) | xurl auth default |
| Set default (command) | xurl auth default APP_NAME [USERNAME] |
| Use app per-request | xurl --app NAME /2/users/me |
| Auth status | xurl auth status |
Post IDs vs URLs: AnywherePOST_IDappears above you can also paste a full post URL (e.g.https://x.com/user/status/1234567890) — xurl extracts the ID automatically.
Usernames: Leading@is optional.@elonmuskandelonmuskboth work.
---
Command Details
Posting
# Simple post
xurl post "Hello world!"
# Post with media (upload first, then attach)
xurl media upload photo.jpg # → note the media_id from response
xurl post "Check this out" --media-id MEDIA_ID
# Multiple media
xurl post "Thread pics" --media-id 111 --media-id 222
# Reply to a post (by ID or URL)
xurl reply 1234567890 "Great point!"
xurl reply https://x.com/user/status/1234567890 "Agreed!"
# Reply with media
xurl reply 1234567890 "Look at this" --media-id MEDIA_ID
# Quote a post
xurl quote 1234567890 "Adding my thoughts"
# Delete your own post
xurl delete 1234567890Reading
# Read a single post (returns author, text, metrics, entities)
xurl read 1234567890
xurl read https://x.com/user/status/1234567890
# Search recent posts (default 10 results)
xurl search "golang"
xurl search "from:elonmusk" -n 20
xurl search "#buildinpublic lang:en" -n 15User Info
# Your own profile
xurl whoami
# Look up any user
xurl user elonmusk
xurl user @XDevelopersTimelines & Mentions
# Home timeline (reverse chronological)
xurl timeline
xurl timeline -n 25
# Your mentions
xurl mentions
xurl mentions -n 20Engagement
# Like / unlike
xurl like 1234567890
xurl unlike 1234567890
# Repost / undo
xurl repost 1234567890
xurl unrepost 1234567890
# Bookmark / remove
xurl bookmark 1234567890
xurl unbookmark 1234567890
# List your bookmarks / likes
xurl bookmarks -n 20
xurl likes -n 20Social Graph
# Follow / unfollow
xurl follow @XDevelopers
xurl unfollow @XDevelopers
# List who you follow / your followers
xurl following -n 50
xurl followers -n 50
# List another user's following/followers
xurl following --of elonmusk -n 20
xurl followers --of elonmusk -n 20
# Block / unblock
xurl block @spammer
xurl unblock @spammer
# Mute / unmute
xurl mute @annoying
xurl unmute @annoyingDirect Messages
# Send a DM
xurl dm @someuser "Hey, saw your post!"
# List recent DM events
xurl dms
xurl dms -n 25Media Upload
# Upload a file (auto‑detects type for images/videos)
xurl media upload photo.jpg
xurl media upload video.mp4
# Specify type and category explicitly
xurl media upload --media-type image/jpeg --category tweet_image photo.jpg
# Check processing status (videos need server‑side processing)
xurl media status MEDIA_ID
xurl media status --wait MEDIA_ID # poll until done
# Full workflow: upload then post
xurl media upload meme.png # response includes media id
xurl post "lol" --media-id MEDIA_ID---
Global Flags
These flags work on every command:
| Flag | Short | Description |
|---|---|---|
--app | Use a specific registered app for this request (overrides default) | |
--auth | Force auth type: oauth1, oauth2, or app | |
--username | -u | Which OAuth2 account to use (if you have multiple) |
--verbose | -v | Forbidden in agent/LLM sessions (can leak auth headers/tokens) |
---
Raw API Access
The shortcut commands cover the most common operations. For anything else, use xurl's raw curl‑style mode — it works with any X API v2 endpoint:
# GET request (default)
xurl /2/users/me
# POST with JSON body
xurl -X POST /2/tweets -d '{"text":"Hello world!"}'
# PUT, PATCH, DELETE
xurl -X DELETE /2/tweets/1234567890
# Custom headers
xurl -H "Content-Type: application/json" /2/some/endpoint
# Force streaming mode
xurl -s /2/tweets/search/stream
# Full URLs also work
xurl https://api.x.com/2/users/me---
Streaming
Streaming endpoints are auto‑detected. Known streaming endpoints include:
/2/tweets/search/stream/2/tweets/sample/stream/2/tweets/sample10/stream
You can force streaming on any endpoint with -s:
xurl -s /2/some/endpoint---
Output Format
All commands return JSON to stdout, pretty‑printed with syntax highlighting. The output structure matches the X API v2 response format. A typical response looks like:
{
"data": {
"id": "1234567890",
"text": "Hello world!"
}
}Errors are also returned as JSON:
{
"errors": [
{
"message": "Not authorized",
"code": 403
}
]
}---
Common Workflows
Post with an image
# 1. Upload the image
xurl media upload photo.jpg
# 2. Copy the media_id from the response, then post
xurl post "Check out this photo!" --media-id MEDIA_IDReply to a conversation
# 1. Read the post to understand context
xurl read https://x.com/user/status/1234567890
# 2. Reply
xurl reply 1234567890 "Here are my thoughts..."Search and engage
# 1. Search for relevant posts
xurl search "topic of interest" -n 10
# 2. Like an interesting one
xurl like POST_ID_FROM_RESULTS
# 3. Reply to it
xurl reply POST_ID_FROM_RESULTS "Great point!"Check your activity
# See who you are
xurl whoami
# Check your mentions
xurl mentions -n 20
# Check your timeline
xurl timeline -n 20Set up multiple apps
# App credentials must already be configured manually outside agent/LLM context.
# Authenticate users on each pre-configured app
xurl auth default prod
xurl auth oauth2 # authenticates on prod app
xurl auth default staging
xurl auth oauth2 # authenticates on staging app
# Switch between them
xurl auth default prod alice # prod app, alice user
xurl --app staging /2/users/me # one-off request against staging---
Error Handling
- Non‑zero exit code on any error.
- API errors are printed as JSON to stdout (so you can still parse them).
- Auth errors suggest re‑running
xurl auth oauth2or checking your tokens. - If a command requires your user ID (like, repost, bookmark, follow, etc.), xurl will automatically fetch it via
/2/users/me. When that endpoint is unreliable, use--username USERNAMEor authenticate withxurl auth oauth2 --app APP_NAME USERNAMEso xurl can fall back to username lookup. - If X returns
client-forbidden/client-not-enrolledafter successful auth, check the app’s X developer-console package and environment. In current testing, moving the app toPay-per-useandProductionfixed/2/*read failures without changing localxurlauth data.
---
Notes
- Rate limits: The X API enforces rate limits per endpoint. If you get a 429 error, wait and retry. Write endpoints (post, reply, like, repost) have stricter limits than read endpoints.
- Scopes: OAuth 2.0 tokens are requested with broad scopes. If you get a 403 on a specific action, your token may lack the required scope — re‑run
xurl auth oauth2to get a fresh token. - Token refresh: OAuth 2.0 tokens auto‑refresh when expired. No manual intervention needed.
- Multiple apps: Each app has its own isolated credentials, tokens, and optional stored
redirect_uri. Configure credentials manually outside agent/LLM context, then switch withxurl auth defaultor--app. - Redirect URI precedence: The effective redirect URI resolves from
REDIRECT_URIin the environment first, then the app's storedredirect_uriin~/.xurl, then the built-in default. - Redirect URI management: Use
xurl auth apps redirect-uri get [NAME],xurl auth apps redirect-uri set NAME URI, orxurl auth apps update NAME --redirect-uri URIto inspect and manage the stored per-app callback value. - X platform enrollment: A successful OAuth callback does not guarantee
/2/*reads will work. If you seeclient-not-enrolled, verify the app is in the correct X package/environment. Current confirmed fix:Apps->Manage apps->Move to package-> choosePay-per-use, then move the app toProduction. - Multiple accounts: You can authenticate multiple OAuth 2.0 accounts per app and switch between them with
--username/-uor set a default withxurl auth default APP USER. - Default user: When no
-uflag is given, xurl uses the default user for the active app (set viaxurl auth default). If no default user is set, it uses the first available token. - Token storage:
~/.xurlis YAML. Each app stores its own credentials and tokens. Never read or send this file to LLM context.
name: Go
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.24'
- name: Build
run: go build -v ./...
- name: Run tests
run: go test -v ./...
- name: Run lint
run: gofmt -l .name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.24"
- name: Run tests
run: go test ./...
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Update npm package version
run: |
VERSION=${GITHUB_REF_NAME#v}
cd npm
npm version "$VERSION" --no-git-tag-version
- name: Unpublish npm version if it exists
continue-on-error: true
run: |
VERSION=${GITHUB_REF_NAME#v}
npm unpublish "@xdevplatform/xurl@${VERSION}" --registry=https://registry.npmjs.org
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish to npm
run: |
cd npm
npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
xurl
.xurl_test
.DS_Store# Added by goreleaser init:
dist/
# This is an example .goreleaser.yml file with some sensible defaults.
# Make sure to check the documentation at https://goreleaser.com
# The lines below are called `modelines`. See `:help modeline`
# Feel free to remove those if you don't want/need to use them.
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
# you may remove this if you don't need go generate
- go generate ./...
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
ldflags:
- -s -w
- -X github.com/xdevplatform/xurl/version.Version={{.Version}}
- -X github.com/xdevplatform/xurl/version.Commit={{.Commit}}
- -X github.com/xdevplatform/xurl/version.BuildDate={{.Date}}
archives:
- formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: [zip]
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
homebrew_casks:
- name: xurl
repository:
owner: xdevplatform
name: homebrew-tap
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
homepage: "https://github.com/xdevplatform/xurl"
description: "Auth-enabled curl-like CLI for the X API"
hooks:
post:
install: |
if OS.mac?
system_command "/usr/bin/xattr", args: ["-dr", "com.apple.quarantine", "#{staged_path}/xurl"]
end
release:
footer: >-
---
Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}",
"args": ["auth", "oauth2"],
"cwd": "${workspaceFolder}"
}
]
}{
"files.watcherExclude": {
"**/target": true
}
}
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/xdevplatform/xurl/auth"
"github.com/xdevplatform/xurl/config"
xurlErrors "github.com/xdevplatform/xurl/errors"
"github.com/xdevplatform/xurl/store"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Helper function to create a temporary token store for testing
func createTempTokenStore(t *testing.T) (*store.TokenStore, string) {
tempDir, err := os.MkdirTemp("", "xurl_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
tempFile := filepath.Join(tempDir, ".xurl")
tokenStore := &store.TokenStore{
Apps: make(map[string]*store.App),
DefaultApp: "default",
FilePath: tempFile,
}
tokenStore.Apps["default"] = &store.App{
OAuth2Tokens: make(map[string]store.Token),
}
return tokenStore, tempDir
}
// Create a mock Auth for testing
func createMockAuth(t *testing.T) (*auth.Auth, string) {
cfg := &config.Config{
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
RedirectURI: "http://localhost:8080/callback",
AuthURL: "https://x.com/i/oauth2/authorize",
TokenURL: "https://api.x.com/2/oauth2/token",
APIBaseURL: "https://api.x.com",
InfoURL: "https://api.x.com/2/users/me",
}
mockAuth := auth.NewAuth(cfg)
tokenStore, tempDir := createTempTokenStore(t)
err := tokenStore.SaveBearerToken("test-bearer-token")
if err != nil {
t.Fatalf("Failed to save bearer token: %v", err)
}
mockAuth.WithTokenStore(tokenStore)
return mockAuth, tempDir
}
func TestNewApiClient(t *testing.T) {
cfg := &config.Config{
APIBaseURL: "https://api.x.com",
}
auth, tempDir := createMockAuth(t)
defer os.RemoveAll(tempDir)
client := NewApiClient(cfg, auth)
assert.Equal(t, cfg.APIBaseURL, client.url, "URL should match config")
assert.Equal(t, auth, client.auth, "Auth should be set correctly")
assert.NotNil(t, client.client, "HTTP client should not be nil")
}
func TestBuildRequest(t *testing.T) {
// Setup
cfg := &config.Config{
APIBaseURL: "https://api.x.com",
}
authMock, tempDir := createMockAuth(t)
defer os.RemoveAll(tempDir)
client := NewApiClient(cfg, authMock)
tests := []struct {
name string
method string
endpoint string
headers []string
data string
authType string
username string
wantMethod string
wantURL string
wantErr bool
}{
{
name: "GET user profile",
method: "GET",
endpoint: "/2/users/me",
headers: []string{"Accept: application/json"},
data: "",
authType: "",
username: "",
wantMethod: "GET",
wantURL: "https://api.x.com/2/users/me",
wantErr: false,
},
{
name: "POST tweet",
method: "POST",
endpoint: "/2/tweets",
headers: []string{"Accept: application/json", "Authorization: Bearer test-token"},
data: `{"text":"Hello world!"}`,
authType: "oauth1",
username: "",
wantMethod: "POST",
wantURL: "https://api.x.com/2/tweets",
wantErr: false,
},
{
name: "Absolute URL",
method: "GET",
endpoint: "https://api.x.com/2/tweets/search/stream",
headers: []string{"Authorization: Bearer test-token"},
data: "",
authType: "app",
username: "",
wantMethod: "GET",
wantURL: "https://api.x.com/2/tweets/search/stream",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
requestOptions := RequestOptions{
Method: tt.method,
Endpoint: tt.endpoint,
Headers: tt.headers,
AuthType: tt.authType,
Username: tt.username,
Data: tt.data,
}
req, err := client.BuildRequest(requestOptions)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantMethod, req.Method)
assert.Equal(t, tt.wantURL, req.URL.String())
for _, header := range tt.headers {
parts := strings.Split(header, ": ")
require.Len(t, parts, 2, "Invalid header format: %s", header)
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
assert.Equal(t, value, req.Header.Get(key))
}
assert.Equal(t, "xurl/dev", req.Header.Get("User-Agent"))
if tt.method == "POST" && tt.data != "" {
assert.Equal(t, "application/json", req.Header.Get("Content-Type"))
}
})
}
}
func TestSendRequest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/2/users/me" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":{"id":"12345","name":"Test User","username":"testuser"}}`))
return
}
if r.URL.Path == "/2/tweets" && r.Method == "POST" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"data":{"id":"67890","text":"Hello world!"}}`))
return
}
if r.URL.Path == "/2/tweets/search/recent" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"errors":[{"message":"Invalid query","code":400}]}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := &config.Config{
APIBaseURL: server.URL,
}
authMock, tempDir := createMockAuth(t)
defer os.RemoveAll(tempDir)
client := NewApiClient(cfg, authMock)
// Test successful GET request
t.Run("Get user profile", func(t *testing.T) {
options := RequestOptions{
Method: "GET",
Endpoint: "/2/users/me",
Headers: []string{"Authorization: Bearer test-token"},
Data: "",
AuthType: "",
Username: "",
Verbose: false,
}
resp, err := client.SendRequest(options)
require.NoError(t, err)
var result map[string]any
err = json.Unmarshal(resp, &result)
require.NoError(t, err, "Failed to parse response")
data, ok := result["data"].(map[string]any)
require.True(t, ok, "Expected data object in response")
assert.Equal(t, "testuser", data["username"], "Username should match")
})
// Test successful POST request
t.Run("Post tweet", func(t *testing.T) {
options := RequestOptions{
Method: "POST",
Endpoint: "/2/tweets",
Headers: []string{"Authorization: Bearer test-token"},
Data: `{"text":"Hello world!"}`,
AuthType: "",
Username: "",
Verbose: false,
}
resp, err := client.SendRequest(options)
require.NoError(t, err)
var result map[string]any
err = json.Unmarshal(resp, &result)
require.NoError(t, err, "Failed to parse response")
data, ok := result["data"].(map[string]any)
require.True(t, ok, "Expected data object in response")
assert.Equal(t, "Hello world!", data["text"], "Tweet text should match")
})
t.Run("Error response", func(t *testing.T) {
options := RequestOptions{
Method: "GET",
Endpoint: "/2/tweets/search/recent",
Headers: []string{"Authorization: Bearer test-token"},
Data: "",
AuthType: "",
Username: "",
Verbose: false,
}
resp, err := client.SendRequest(options)
assert.Error(t, err, "Expected an error")
assert.Nil(t, resp, "Response should be nil")
assert.True(t, xurlErrors.IsAPIError(err), "Expected API error")
})
}
func TestGetAuthHeader(t *testing.T) {
cfg := &config.Config{
APIBaseURL: "https://api.x.com",
}
t.Run("No auth set", func(t *testing.T) {
client := NewApiClient(cfg, nil)
_, err := client.getAuthHeader("GET", "https://api.x.com/2/users/me", "", "")
assert.Error(t, err, "Expected an error")
assert.True(t, xurlErrors.IsAuthError(err), "Expected auth error")
})
t.Run("Invalid auth type", func(t *testing.T) {
authMock, tempDir := createMockAuth(t)
defer os.RemoveAll(tempDir)
client := NewApiClient(cfg, authMock)
_, err := client.getAuthHeader("GET", "https://api.x.com/2/users/me", "invalid", "")
assert.Error(t, err, "Expected an error")
assert.True(t, xurlErrors.IsAuthError(err), "Expected auth error")
})
t.Run("Auto-detect uses named app bearer token when --app is set", func(t *testing.T) {
tokenStore, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
tokenStore.AddApp("my-app", "id", "secret")
// Bearer token only in my-app, not in default
tokenStore.SaveBearerTokenForApp("my-app", "bearer-my-app")
a := auth.NewAuth(&config.Config{}).WithTokenStore(tokenStore).WithAppName("my-app")
client := NewApiClient(cfg, a)
header, err := client.getAuthHeader("GET", "https://api.x.com/2/users/me", "", "")
require.NoError(t, err)
assert.Equal(t, "Bearer bearer-my-app", header)
})
t.Run("Auto-detect falls back to default app when no --app flag", func(t *testing.T) {
tokenStore, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
tokenStore.AddApp("other-app", "id", "secret")
// Bearer token only in default app
tokenStore.SaveBearerTokenForApp("default", "bearer-default")
tokenStore.SaveBearerTokenForApp("other-app", "bearer-other")
// No WithAppName — should use default
a := auth.NewAuth(&config.Config{}).WithTokenStore(tokenStore)
client := NewApiClient(cfg, a)
header, err := client.getAuthHeader("GET", "https://api.x.com/2/users/me", "", "")
require.NoError(t, err)
assert.Equal(t, "Bearer bearer-default", header)
})
}
func TestStreamRequest(t *testing.T) {
// This is a basic test for the StreamRequest method
// A more comprehensive test would require mocking the streaming response
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/2/tweets/search/stream" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// In a real test, we would write multiple JSON objects with flushing
// but for this simple test, we'll just close the connection
return
}
if r.URL.Path == "/2/tweets/search/stream/error" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"errors":[{"message":"Invalid rule","code":400}]}`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
cfg := &config.Config{
APIBaseURL: server.URL,
}
authMock, tempDir := createMockAuth(t)
defer os.RemoveAll(tempDir)
client := NewApiClient(cfg, authMock)
t.Run("Stream error response", func(t *testing.T) {
options := RequestOptions{
Method: "GET",
Endpoint: "/2/tweets/search/stream/error",
Headers: []string{"Authorization: Bearer test-token"},
Data: "",
AuthType: "",
Username: "",
Verbose: false,
}
err := client.StreamRequest(options)
assert.Error(t, err, "Expected an error")
assert.True(t, xurlErrors.IsAPIError(err), "Expected API error")
})
}
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"bufio"
"mime/multipart"
"os"
"path/filepath"
"github.com/xdevplatform/xurl/auth"
"github.com/xdevplatform/xurl/config"
xurlErrors "github.com/xdevplatform/xurl/errors"
"github.com/xdevplatform/xurl/version"
)
// RequestOptions contains common options for API requests
type RequestOptions struct {
Method string
Endpoint string
Headers []string
Data string
AuthType string
Username string
Verbose bool
Trace bool
}
// MultipartOptions contains options specific to multipart requests
type MultipartOptions struct {
RequestOptions
FormFields map[string]string
FileField string
FilePath string
FileName string
FileData []byte
}
// Client is an interface for API clients
type Client interface {
BuildRequest(requestOptions RequestOptions) (*http.Request, error)
BuildMultipartRequest(options MultipartOptions) (*http.Request, error)
SendRequest(options RequestOptions) (json.RawMessage, error)
StreamRequest(options RequestOptions) error
SendMultipartRequest(options MultipartOptions) (json.RawMessage, error)
}
// ApiClient handles API requests
type ApiClient struct {
url string
client *http.Client
auth *auth.Auth
}
// NewApiClient creates a new ApiClient
func NewApiClient(config *config.Config, auth *auth.Auth) *ApiClient {
return &ApiClient{
url: config.APIBaseURL,
client: &http.Client{Timeout: 30 * time.Second},
auth: auth,
}
}
// BuildRequest builds an HTTP request
func (c *ApiClient) BuildRequest(requestOptions RequestOptions) (*http.Request, error) {
httpMethod := strings.ToUpper(requestOptions.Method)
var body io.Reader
contentType := ""
if requestOptions.Data != "" && (httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH") {
body = bytes.NewBufferString(requestOptions.Data)
var js json.RawMessage
if json.Unmarshal([]byte(requestOptions.Data), &js) == nil {
contentType = "application/json"
} else {
contentType = "application/x-www-form-urlencoded"
}
}
return c.buildBaseRequest(
requestOptions.Method,
requestOptions.Endpoint,
body,
contentType,
requestOptions.Headers,
requestOptions.AuthType,
requestOptions.Username,
requestOptions.Trace,
)
}
// BuildMultipartRequest builds an HTTP request with multipart form data
func (c *ApiClient) BuildMultipartRequest(options MultipartOptions) (*http.Request, error) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
// Handle file from path
if options.FileField != "" && options.FilePath != "" {
file, err := os.Open(options.FilePath)
if err != nil {
return nil, xurlErrors.NewIOError(fmt.Errorf("error opening file: %v", err))
}
defer file.Close()
part, err := writer.CreateFormFile(options.FileField, filepath.Base(options.FilePath))
if err != nil {
return nil, xurlErrors.NewIOError(fmt.Errorf("error creating form file: %v", err))
}
if _, err := io.Copy(part, file); err != nil {
return nil, xurlErrors.NewIOError(fmt.Errorf("error copying file content: %v", err))
}
} else if options.FileField != "" && len(options.FileData) > 0 { // Handle file from buffer
part, err := writer.CreateFormFile(options.FileField, options.FileName)
if err != nil {
return nil, xurlErrors.NewIOError(fmt.Errorf("error creating form file: %v", err))
}
if _, err := part.Write(options.FileData); err != nil {
return nil, xurlErrors.NewIOError(fmt.Errorf("error writing file data: %v", err))
}
}
for key, value := range options.FormFields {
if err := writer.WriteField(key, value); err != nil {
return nil, xurlErrors.NewIOError(fmt.Errorf("error writing form field: %v", err))
}
}
if err := writer.Close(); err != nil {
return nil, xurlErrors.NewIOError(fmt.Errorf("error closing multipart writer: %v", err))
}
// Use the common base request builder with the multipart content type
return c.buildBaseRequest(
options.Method,
options.Endpoint,
body,
writer.FormDataContentType(),
options.Headers,
options.AuthType,
options.Username,
options.Trace,
)
}
// SendRequest sends an HTTP request
func (c *ApiClient) SendRequest(options RequestOptions) (json.RawMessage, error) {
req, err := c.BuildRequest(options)
if err != nil {
return nil, xurlErrors.NewHTTPError(err)
}
c.logRequest(req, options.Verbose)
resp, err := c.client.Do(req)
if err != nil {
return nil, xurlErrors.NewHTTPError(err)
}
defer resp.Body.Close()
return c.processResponse(resp, options.Verbose)
}
// SendMultipartRequest sends an HTTP request with multipart form data
func (c *ApiClient) SendMultipartRequest(options MultipartOptions) (json.RawMessage, error) {
req, err := c.BuildMultipartRequest(options)
if err != nil {
return nil, err
}
c.logRequest(req, options.Verbose)
resp, err := c.client.Do(req)
if err != nil {
return nil, xurlErrors.NewHTTPError(err)
}
defer resp.Body.Close()
return c.processResponse(resp, options.Verbose)
}
// StreamRequest sends an HTTP request and streams the response
func (c *ApiClient) StreamRequest(options RequestOptions) error {
req, err := c.BuildRequest(options)
if err != nil {
return xurlErrors.NewHTTPError(err)
}
if options.Verbose {
fmt.Printf("\033[1;34m> %s\033[0m %s\n", req.Method, req.URL)
for key, values := range req.Header {
for _, value := range values {
fmt.Printf("\033[1;36m> %s\033[0m: %s\n", key, value)
}
}
fmt.Println()
}
client := &http.Client{
Timeout: 0,
}
fmt.Printf("\033[1;32mConnecting to streaming endpoint: %s\033[0m\n", options.Endpoint)
resp, err := client.Do(req)
if err != nil {
return xurlErrors.NewHTTPError(err)
}
defer resp.Body.Close()
if options.Verbose {
fmt.Printf("\033[1;31m< %s\033[0m\n", resp.Status)
for key, values := range resp.Header {
for _, value := range values {
fmt.Printf("\033[1;32m< %s\033[0m: %s\n", key, value)
}
}
fmt.Println()
}
if resp.StatusCode >= 400 {
body, err := io.ReadAll(resp.Body)
if err != nil {
return xurlErrors.NewIOError(err)
}
var js json.RawMessage
if err := json.Unmarshal(body, &js); err != nil {
return xurlErrors.NewJSONError(err)
}
return xurlErrors.NewAPIError(js)
}
scanner := bufio.NewScanner(resp.Body)
const maxScanTokenSize = 1024 * 1024
buf := make([]byte, maxScanTokenSize)
scanner.Buffer(buf, maxScanTokenSize)
fmt.Println("\033[1;32m--- Streaming response started ---\033[0m")
fmt.Println("\033[1;32m--- Press Ctrl+C to stop ---\033[0m")
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
// We can't pretty-print streaming responses
fmt.Println(line)
}
if err := scanner.Err(); err != nil {
if err == bufio.ErrTooLong {
return xurlErrors.NewIOError(fmt.Errorf("line too long: increase buffer size"))
}
return xurlErrors.NewIOError(err)
}
fmt.Println("\033[1;32m--- End of stream ---\033[0m")
return nil
}
// buildBaseRequest creates the base HTTP request with common headers and settings
func (c *ApiClient) buildBaseRequest(method, endpoint string, body io.Reader, contentType string, headers []string, authType, username string, trace bool) (*http.Request, error) {
httpMethod := strings.ToUpper(method)
// Build the full URL
url := endpoint
if !strings.HasPrefix(strings.ToLower(endpoint), "http") {
url = c.url
if !strings.HasSuffix(url, "/") {
url += "/"
}
if strings.HasPrefix(endpoint, "/") {
url += endpoint[1:]
} else {
url += endpoint
}
}
// Create the request
req, err := http.NewRequest(httpMethod, url, body)
if err != nil {
return nil, xurlErrors.NewHTTPError(err)
}
// Add headers
for _, header := range headers {
parts := strings.SplitN(header, ":", 2)
if len(parts) == 2 {
req.Header.Add(strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]))
}
}
// Set content type if provided
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
// Add authorization header if not already set
if req.Header.Get("Authorization") == "" {
authHeader, err := c.getAuthHeader(httpMethod, url, authType, username)
if err == nil {
req.Header.Add("Authorization", authHeader)
}
}
// Add common headers
req.Header.Add("User-Agent", "xurl/"+version.Version)
if trace {
req.Header.Add("X-B3-Flags", "1")
}
return req, nil
}
// GetAuthHeader gets the authorization header for a request
func (c *ApiClient) getAuthHeader(method, url string, authType string, username string) (string, error) {
if c.auth == nil {
return "", xurlErrors.NewAuthError("AuthNotSet", errors.New("auth not set"))
}
if authType != "" {
switch strings.ToLower(authType) {
case "oauth1":
return c.auth.GetOAuth1Header(method, url, nil)
case "oauth2":
return c.auth.GetOAuth2Header(username)
case "app":
return c.auth.GetBearerTokenHeader()
default:
return "", xurlErrors.NewAuthError("InvalidAuthType", fmt.Errorf("invalid auth type: %s", authType))
}
}
// If no auth type is specified, try to use the first OAuth2 token
appName := c.auth.AppName()
token := c.auth.TokenStore.GetFirstOAuth2TokenForApp(appName)
if token != nil {
accessToken, err := c.auth.GetOAuth2Header(username)
if err == nil {
return accessToken, nil
}
}
// If no OAuth2 token is available, try to use the first OAuth1 token
token = c.auth.TokenStore.GetOAuth1TokensForApp(appName)
if token != nil {
authHeader, err := c.auth.GetOAuth1Header(method, url, nil)
if err == nil {
return authHeader, nil
}
}
// If no OAuth1 token is available, try to use the bearer token
bearerToken, err := c.auth.GetBearerTokenHeader()
if err == nil {
return bearerToken, nil
}
// If no authentication method is available, return an error
return "", xurlErrors.NewAuthError("NoAuthMethod", errors.New("no authentication method available"))
}
// logRequest logs request details if verbose mode is enabled
func (c *ApiClient) logRequest(req *http.Request, verbose bool) {
if verbose {
fmt.Printf("\033[1;34m> %s\033[0m %s\n", req.Method, req.URL)
for key, values := range req.Header {
for _, value := range values {
fmt.Printf("\033[1;36m> %s\033[0m: %s\n", key, value)
}
}
fmt.Println()
}
}
// processResponse handles common response processing logic
func (c *ApiClient) processResponse(resp *http.Response, verbose bool) (json.RawMessage, error) {
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, xurlErrors.NewIOError(err)
}
if verbose {
fmt.Printf("\033[1;31m< %s\033[0m\n", resp.Status)
for key, values := range resp.Header {
for _, value := range values {
fmt.Printf("\033[1;32m< %s\033[0m: %s\n", key, value)
}
}
fmt.Println()
}
var js json.RawMessage
if len(responseBody) > 0 {
if err := json.Unmarshal(responseBody, &js); err != nil {
if resp.StatusCode >= 400 {
return nil, xurlErrors.NewHTTPError(fmt.Errorf("HTTP error: %s", resp.Status))
}
js = json.RawMessage("{}")
}
} else {
js = json.RawMessage("{}")
}
if resp.StatusCode >= 400 {
return nil, xurlErrors.NewAPIError(js)
}
return js, nil
}
package api
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsStreamingEndpoint(t *testing.T) {
testCases := []struct {
endpoint string
expected bool
}{
// Test exact matches
{"/2/tweets/search/stream", true},
{"/2/tweets/sample/stream", true},
{"/2/tweets/sample10/stream", true},
{"/2/tweets/firehose/stream", true},
{"/2/tweets/firehose/stream/lang/en", true},
{"/2/tweets/firehose/stream/lang/ja", true},
{"/2/tweets/firehose/stream/lang/ko", true},
{"/2/tweets/firehose/stream/lang/pt", true},
// Test with trailing slash
{"/2/tweets/search/stream/", true},
// Test with query parameters
{"/2/tweets/search/stream?query=test", true},
// Test with full URL
{"https://api.x.com/2/tweets/search/stream", true},
{"http://api.x.com/2/tweets/search/stream", true},
{"https://api.x.com/2/tweets/search/stream?query=test", true},
// Test non-streaming endpoints
{"/2/tweets/search/recent", false},
{"/2/users/me", false},
{"https://api.x.com/2/users/me", false},
{"/not/a/streaming/endpoint", false},
{"", false},
}
for _, tc := range testCases {
t.Run(tc.endpoint, func(t *testing.T) {
result := IsStreamingEndpoint(tc.endpoint)
assert.Equal(t, tc.expected, result, "IsStreamingEndpoint(%q) should return %v", tc.endpoint, tc.expected)
})
}
}
package api
import (
"strings"
)
// StreamingEndpoints is a map of endpoint prefixes that should be streamed
var StreamingEndpoints = map[string]bool{
"/2/tweets/search/stream": true,
"/2/tweets/sample/stream": true,
"/2/tweets/sample10/stream": true,
"/2/tweets/firehose/stream": true,
"/2/tweets/firehose/stream/lang/en": true,
"/2/tweets/firehose/stream/lang/ja": true,
"/2/tweets/firehose/stream/lang/ko": true,
"/2/tweets/firehose/stream/lang/pt": true,
}
// IsStreamingEndpoint checks if an endpoint should be streamed
func IsStreamingEndpoint(endpoint string) bool {
path := endpoint
if strings.HasPrefix(strings.ToLower(endpoint), "http") {
parsedURL := strings.SplitN(endpoint, "/", 4)
if len(parsedURL) >= 4 {
path = "/" + parsedURL[3]
}
}
// Remove query parameters if present
if queryIndex := strings.Index(path, "?"); queryIndex != -1 {
path = path[:queryIndex]
}
normalizedEndpoint := strings.TrimSuffix(path, "/")
return StreamingEndpoints[normalizedEndpoint]
}
package api
import (
"encoding/json"
"fmt"
"github.com/xdevplatform/xurl/utils"
)
// ExecuteRequest handles the execution of a regular API request
func ExecuteRequest(options RequestOptions, client Client) error {
response, clientErr := client.SendRequest(options)
if clientErr != nil {
return handleRequestError(clientErr)
}
return utils.FormatAndPrintResponse(response)
}
// ExecuteStreamRequest handles the execution of a streaming API request
func ExecuteStreamRequest(options RequestOptions, client Client) error {
clientErr := client.StreamRequest(options)
if clientErr != nil {
return handleRequestError(clientErr)
}
return nil
}
// handleRequestError processes API client errors in a consistent way
func handleRequestError(clientErr error) error {
var rawJSON json.RawMessage
json.Unmarshal([]byte(clientErr.Error()), &rawJSON)
utils.FormatAndPrintResponse(rawJSON)
return fmt.Errorf("request failed")
}
// formatAndPrintResponse formats and prints API responses
// HandleRequest determines the type of request and executes it accordingly
func HandleRequest(options RequestOptions, forceStream bool, mediaFile string, client Client) error {
if IsMediaAppendRequest(options.Endpoint, mediaFile) {
response, err := HandleMediaAppendRequest(options, mediaFile, client)
if err != nil {
return err
}
return utils.FormatAndPrintResponse(response)
}
shouldStream := forceStream || IsStreamingEndpoint(options.Endpoint)
if shouldStream {
return ExecuteStreamRequest(options, client)
} else {
return ExecuteRequest(options, client)
}
}
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// MockApiClient is a mock implementation of the ApiClient for testing
type MockApiClient struct {
mock.Mock
}
func (m *MockApiClient) SendRequest(options RequestOptions) (json.RawMessage, error) {
args := m.Called(options)
return args.Get(0).(json.RawMessage), args.Error(1)
}
func (m *MockApiClient) SendMultipartRequest(options MultipartOptions) (json.RawMessage, error) {
args := m.Called(options)
return args.Get(0).(json.RawMessage), args.Error(1)
}
func (m *MockApiClient) BuildRequest(requestOptions RequestOptions) (*http.Request, error) {
args := m.Called(requestOptions)
return args.Get(0).(*http.Request), args.Error(1)
}
func (m *MockApiClient) BuildMultipartRequest(options MultipartOptions) (*http.Request, error) {
args := m.Called(options)
return args.Get(0).(*http.Request), args.Error(1)
}
func (m *MockApiClient) StreamRequest(options RequestOptions) error {
args := m.Called(options.Method, options.Endpoint, options.Headers, options.Data, options.AuthType, options.Username, options.Verbose)
return args.Error(0)
}
// Helper function to create a temporary test file
func createTempTestFile(t *testing.T, size int) (string, []byte) {
tempFile, err := os.CreateTemp("", "media_test_*.txt")
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
data := make([]byte, size)
for i := range data {
data[i] = byte(i % 256)
}
if _, err := tempFile.Write(data); err != nil {
t.Fatalf("Failed to write to temp file: %v", err)
}
if err := tempFile.Close(); err != nil {
t.Fatalf("Failed to close temp file: %v", err)
}
return tempFile.Name(), data
}
func TestNewMediaUploader(t *testing.T) {
mockClient := new(MockApiClient)
tempFile, _ := createTempTestFile(t, 1024)
defer os.Remove(tempFile)
uploader, err := NewMediaUploader(mockClient, tempFile, true, false, "oauth2", "testuser", []string{})
assert.NoError(t, err)
assert.NotNil(t, uploader)
assert.Equal(t, tempFile, uploader.filePath)
assert.Equal(t, int64(1024), uploader.fileSize)
assert.Equal(t, true, uploader.verbose)
assert.Equal(t, "oauth2", uploader.authType)
assert.Equal(t, "testuser", uploader.username)
uploader, err = NewMediaUploader(mockClient, "nonexistent.txt", false, false, "oauth2", "testuser", []string{})
assert.Error(t, err)
assert.Nil(t, uploader)
tempDir, err := os.MkdirTemp("", "media_test_dir")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
defer os.RemoveAll(tempDir)
uploader, err = NewMediaUploader(mockClient, tempDir, false, false, "oauth2", "testuser", []string{})
assert.Error(t, err)
assert.Nil(t, uploader)
}
func TestMediaUploader_Init(t *testing.T) {
mockClient := new(MockApiClient)
tempFile, _ := createTempTestFile(t, 1024)
defer os.Remove(tempFile)
uploader, err := NewMediaUploader(mockClient, tempFile, false, false, "oauth2", "testuser", []string{})
assert.NoError(t, err)
initResponse := json.RawMessage(`{
"data": {
"id": "test_media_id",
"expires_after_secs": 3600,
"media_key": "test_media_key"
}
}`)
expectedUrl := MediaEndpoint + "/initialize"
data := InitRequest{
TotalBytes: 1024,
MediaType: "image/jpeg",
MediaCategory: "tweet_image",
}
jsonData, err := json.Marshal(data)
if err != nil {
t.Fatalf("Failed to marshal jsonData: %v", err)
}
requestOptions := RequestOptions{
Method: "POST",
Endpoint: expectedUrl,
Headers: []string{},
Data: string(jsonData),
AuthType: "oauth2",
Username: "testuser",
Verbose: false,
}
mockClient.On("SendRequest", requestOptions).Return(initResponse, nil)
err = uploader.Init("image/jpeg", "tweet_image")
assert.NoError(t, err)
assert.Equal(t, "test_media_id", uploader.GetMediaID())
mockClient.AssertExpectations(t)
mockClient = new(MockApiClient)
uploader, err = NewMediaUploader(mockClient, tempFile, false, false, "oauth2", "testuser", []string{})
assert.NoError(t, err)
mockClient.On("SendRequest", requestOptions).Return(json.RawMessage("{}"), assert.AnError)
err = uploader.Init("image/jpeg", "tweet_image")
assert.Error(t, err)
mockClient.AssertExpectations(t)
}
func TestMediaUploader_Append(t *testing.T) {
mockClient := new(MockApiClient)
fileSize := 8 * 1024 * 1024
tempFile, data := createTempTestFile(t, fileSize)
defer os.Remove(tempFile)
uploader, err := NewMediaUploader(mockClient, tempFile, false, false, "oauth2", "testuser", []string{})
assert.NoError(t, err)
mediaID := "test_media_id"
uploader.SetMediaID(mediaID)
requestOptions := RequestOptions{
Method: "POST",
Endpoint: MediaEndpoint + "/" + mediaID + "/append",
Headers: []string{},
Data: "",
AuthType: "oauth2",
Username: "testuser",
Verbose: false,
}
multipartOptions := MultipartOptions{
RequestOptions: requestOptions,
FormFields: map[string]string{"segment_index": "0"},
FileField: "media",
FileName: filepath.Base(tempFile),
FileData: data[:4*1024*1024],
}
multipartOptions1 := MultipartOptions{
RequestOptions: requestOptions,
FormFields: map[string]string{"segment_index": "1"},
FileField: "media",
FileName: filepath.Base(tempFile),
FileData: data[4*1024*1024:],
}
mockClient.On("SendMultipartRequest", multipartOptions).Return(json.RawMessage("{}"), nil)
mockClient.On("SendMultipartRequest", multipartOptions1).Return(json.RawMessage("{}"), nil)
err = uploader.Append()
assert.NoError(t, err)
mockClient.AssertExpectations(t)
uploader.SetMediaID("")
err = uploader.Append()
assert.Error(t, err)
assert.Contains(t, err.Error(), "media ID not set")
}
func TestMediaUploader_Finalize(t *testing.T) {
mockClient := new(MockApiClient)
tempFile, _ := createTempTestFile(t, 1024)
defer os.Remove(tempFile)
uploader, err := NewMediaUploader(mockClient, tempFile, false, false, "oauth2", "testuser", []string{})
assert.NoError(t, err)
uploader.SetMediaID("test_media_id")
finalizeResponse := json.RawMessage(`{
"data": {
"id": "test_media_id",
"media_key": "test_media_key"
}
}`)
expectedUrl := MediaEndpoint + fmt.Sprintf("/%s/finalize", uploader.GetMediaID())
requestOptions := RequestOptions{
Method: "POST",
Endpoint: expectedUrl,
Headers: []string{},
Data: "",
AuthType: "oauth2",
Username: "testuser",
Verbose: false,
}
mockClient.On("SendRequest", requestOptions).Return(finalizeResponse, nil)
response, err := uploader.Finalize()
assert.NoError(t, err)
assert.Equal(t, finalizeResponse, response)
mockClient.AssertExpectations(t)
uploader.SetMediaID("")
response, err = uploader.Finalize()
assert.Error(t, err)
assert.Contains(t, err.Error(), "media ID not set")
assert.Nil(t, response)
}
func TestMediaUploader_CheckStatus(t *testing.T) {
mockClient := new(MockApiClient)
tempFile, _ := createTempTestFile(t, 1024)
defer os.Remove(tempFile)
uploader, err := NewMediaUploader(mockClient, tempFile, false, false, "oauth2", "testuser", []string{})
assert.NoError(t, err)
uploader.SetMediaID("test_media_id")
statusResponse := json.RawMessage(`{
"data": {
"id": "test_media_id",
"media_key": "test_media_key",
"processing_info": {
"state": "succeeded",
"progress_percent": 100
}
}
}`)
expectedUrl := MediaEndpoint + "?command=STATUS&media_id=test_media_id"
requestOptions := RequestOptions{
Method: "GET",
Endpoint: expectedUrl,
Headers: []string{},
Data: "",
AuthType: "oauth2",
Username: "testuser",
Verbose: false,
}
mockClient.On("SendRequest", requestOptions).Return(statusResponse, nil)
response, err := uploader.CheckStatus()
assert.NoError(t, err)
assert.Equal(t, statusResponse, response)
mockClient.AssertExpectations(t)
uploader.SetMediaID("")
response, err = uploader.CheckStatus()
assert.Error(t, err)
assert.Contains(t, err.Error(), "media ID not set")
assert.Nil(t, response)
}
func TestMediaUploader_WaitForProcessing(t *testing.T) {
mockClient := new(MockApiClient)
tempFile, _ := createTempTestFile(t, 1024)
defer os.Remove(tempFile)
uploader, err := NewMediaUploader(mockClient, tempFile, false, false, "oauth2", "testuser", []string{})
assert.NoError(t, err)
uploader.SetMediaID("test_media_id")
inProgressResponse := json.RawMessage(`{
"data": {
"id": "test_media_id",
"media_key": "test_media_key",
"processing_info": {
"state": "in_progress",
"check_after_secs": 1,
"progress_percent": 50
}
}
}`)
successResponse := json.RawMessage(`{
"data": {
"id": "test_media_id",
"media_key": "test_media_key",
"processing_info": {
"state": "succeeded",
"progress_percent": 100
}
}
}`)
expectedUrl := MediaEndpoint + "?command=STATUS&media_id=test_media_id"
requestOptions := RequestOptions{
Method: "GET",
Endpoint: expectedUrl,
Headers: []string{},
Data: "",
AuthType: "oauth2",
Username: "testuser",
Verbose: false,
}
mockClient.On("SendRequest", requestOptions).Return(inProgressResponse, nil).Once()
mockClient.On("SendRequest", requestOptions).Return(successResponse, nil).Once()
response, err := uploader.WaitForProcessing()
assert.NoError(t, err)
assert.Equal(t, successResponse, response)
mockClient.AssertExpectations(t)
failedResponse := json.RawMessage(`{
"data": {
"id": "test_media_id",
"media_key": "test_media_key",
"processing_info": {
"state": "failed",
"progress_percent": 0
}
}
}`)
requestOptions = RequestOptions{
Method: "GET",
Endpoint: expectedUrl,
Headers: []string{},
Data: "",
AuthType: "oauth2",
Username: "testuser",
Verbose: false,
}
mockClient.On("SendRequest", requestOptions).Return(failedResponse, nil).Once()
response, err = uploader.WaitForProcessing()
assert.Error(t, err)
assert.Contains(t, err.Error(), "media processing failed")
assert.Nil(t, response)
uploader.SetMediaID("")
response, err = uploader.WaitForProcessing()
assert.Error(t, err)
assert.Contains(t, err.Error(), "media ID not set")
assert.Nil(t, response)
}
func TestExecuteMediaUpload(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, MediaEndpoint) {
command := ExtractCommand(r.URL.Path)
switch command {
case "initialize":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"data": {
"id": "test_media_id",
"expires_after_secs": 3600,
"media_key": "test_media_key"
}
}`))
case "append":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
case "finalize":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"data": {
"id": "test_media_id",
"media_key": "test_media_key"
}
}`))
case "status":
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"data": {
"id": "test_media_id",
"media_key": "test_media_key",
"processing_info": {
"state": "succeeded",
"progress_percent": 100
}
}
}`))
default:
w.WriteHeader(http.StatusBadRequest)
}
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
client := &ApiClient{
url: server.URL,
client: &http.Client{Timeout: 30 * time.Second},
}
tempFile, _ := createTempTestFile(t, 1024)
defer os.Remove(tempFile)
err := ExecuteMediaUpload(tempFile, "image/jpeg", "tweet_image", "oauth2", "testuser", false, false, false, []string{}, client)
assert.NoError(t, err)
err = ExecuteMediaUpload("nonexistent.txt", "image/jpeg", "tweet_image", "oauth2", "testuser", false, false, false, []string{}, client)
assert.Error(t, err)
}
func TestExecuteMediaStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == MediaEndpoint && r.URL.Query().Get("command") == "STATUS" {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{
"data": {
"id": "test_media_id",
"media_key": "test_media_key",
"processing_info": {
"state": "succeeded",
"progress_percent": 100
}
}
}`))
} else {
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
client := &ApiClient{
url: server.URL,
client: &http.Client{Timeout: 30 * time.Second},
}
err := ExecuteMediaStatus("test_media_id", "oauth2", "testuser", false, false, false, []string{}, client)
assert.NoError(t, err)
}
func TestExtractMediaID(t *testing.T) {
testCases := []struct {
url string
expected string
}{
{"/2/media/upload/123456/append", "123456"},
{"/2/media/upload/123456/finalize", "123456"},
{"/2/media/upload?command=STATUS&media_id=123456", "123456"},
{"/2/media/upload/initialize", ""},
{"/2/media/upload", ""},
{"api.x.com/2/media/upload/123456/append", "123456"},
{"api.x.com/2/media/upload/123456/finalize", "123456"},
{"api.x.com/2/media/upload?command=STATUS&media_id=123456", "123456"},
{"", ""},
}
for _, tc := range testCases {
result := ExtractMediaID(tc.url)
assert.Equal(t, tc.expected, result)
}
}
func TestExtractSegmentIndex(t *testing.T) {
testCases := []struct {
url string
data string
expected string
}{
{"/2/media/upload/123/append", "", ""},
{"/2/media/upload/123/append", "{\"segment_index\": \"1\"}", "1"},
}
for _, tc := range testCases {
result := ExtractSegmentIndex(tc.data)
assert.Equal(t, tc.expected, result)
}
}
func TestIsMediaAppendRequest(t *testing.T) {
testCases := []struct {
url string
mediaFile string
expected bool
}{
{"/2/media/upload/123/append", "file.jpg", true},
{"/2/media/upload/initialize", "file.jpg", false},
{"/2/media/upload/123/append", "", false},
{"/2/users/me", "file.jpg", false},
{"", "", false},
}
for _, tc := range testCases {
result := IsMediaAppendRequest(tc.url, tc.mediaFile)
assert.Equal(t, tc.expected, result)
}
}
func TestHandleMediaAppendRequest(t *testing.T) {
mockClient := new(MockApiClient)
tempFile, _ := createTempTestFile(t, 1024)
defer os.Remove(tempFile)
mockResponse := json.RawMessage(`{}`)
url := "/2/media/upload/123456/append"
requestOptions := RequestOptions{
Method: "POST",
Endpoint: url,
Headers: []string{},
Data: "",
AuthType: "oauth2",
Username: "testuser",
Verbose: false,
}
multipartOptions := MultipartOptions{
RequestOptions: requestOptions,
FormFields: map[string]string{"segment_index": "0"},
FileField: "media",
FilePath: tempFile,
FileName: filepath.Base(tempFile),
FileData: []byte{},
}
mockClient.On("SendMultipartRequest", multipartOptions).Return(mockResponse, nil).Twice()
response, err := HandleMediaAppendRequest(requestOptions, tempFile, mockClient)
assert.NoError(t, err)
assert.Equal(t, mockResponse, response)
response, err = HandleMediaAppendRequest(requestOptions, tempFile, mockClient)
assert.NoError(t, err)
assert.Equal(t, mockResponse, response)
requestOptionsNoMediaID := requestOptions
requestOptionsNoMediaID.Endpoint = "/2/media/upload?command=APPEND"
response, err = HandleMediaAppendRequest(requestOptionsNoMediaID, tempFile, mockClient)
assert.Error(t, err)
assert.Contains(t, err.Error(), "media_id is required")
assert.Nil(t, response)
mockClient.AssertExpectations(t)
}
package api
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/xdevplatform/xurl/utils"
)
const (
// MediaEndpoint is the endpoint for media uploads
MediaEndpoint = "/2/media/upload"
)
// MediaUploader handles media upload operations
type MediaUploader struct {
client Client
mediaID string
filePath string
fileSize int64
verbose bool
authType string
username string
headers []string
trace bool
}
type InitRequest struct {
TotalBytes int64 `json:"total_bytes"`
MediaType string `json:"media_type"`
MediaCategory string `json:"media_category"`
}
// NewMediaUploader creates a new MediaUploader
func NewMediaUploader(client Client, filePath string, verbose, trace bool, authType string, username string, headers []string) (*MediaUploader, error) {
fileInfo, err := os.Stat(filePath)
if err != nil {
return nil, fmt.Errorf("error accessing file: %v", err)
}
// Check if it's a regular file
if !fileInfo.Mode().IsRegular() {
return nil, fmt.Errorf("%s is not a regular file", filePath)
}
return &MediaUploader{
client: client,
filePath: filePath,
fileSize: fileInfo.Size(),
verbose: verbose,
authType: authType,
username: username,
headers: headers,
trace: trace,
}, nil
}
func NewMediaUploaderWithoutFile(client Client, verbose, trace bool, authType string, username string, headers []string) *MediaUploader {
return &MediaUploader{
client: client,
verbose: verbose,
authType: authType,
username: username,
headers: headers,
trace: trace,
}
}
// Init initializes the media upload
func (m *MediaUploader) Init(mediaType string, mediaCategory string) error {
if m.verbose {
fmt.Printf("\033[32mInitializing media upload...\033[0m\n")
}
finalUrl := MediaEndpoint +
"/initialize"
body := InitRequest{
TotalBytes: m.fileSize,
MediaType: mediaType,
MediaCategory: mediaCategory,
}
jsonData, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("error marshalling body: %v", err)
}
requestOptions := RequestOptions{
Method: "POST",
Endpoint: finalUrl,
Headers: m.headers,
Data: string(jsonData),
AuthType: m.authType,
Username: m.username,
Verbose: m.verbose,
Trace: m.trace,
}
response, clientErr := m.client.SendRequest(requestOptions)
if clientErr != nil {
return fmt.Errorf("init request failed: %v", clientErr)
}
var initResponse struct {
Data struct {
ID string `json:"id"`
ExpiresAfterSecs int `json:"expires_after_secs"`
MediaKey string `json:"media_key"`
} `json:"data"`
}
if err := json.Unmarshal(response, &initResponse); err != nil {
return fmt.Errorf("failed to parse init response: %v", err)
}
m.mediaID = initResponse.Data.ID
if m.verbose {
utils.FormatAndPrintResponse(initResponse)
}
return nil
}
// Append uploads the media in chunks
func (m *MediaUploader) Append() error {
if m.mediaID == "" {
return fmt.Errorf("media ID not set, call Init first")
}
if m.verbose {
fmt.Printf("\033[32mUploading media in chunks...\033[0m\n")
}
// Open the file
file, err := os.Open(m.filePath)
if err != nil {
return fmt.Errorf("error opening file: %v", err)
}
defer file.Close()
// Upload in chunks of 4MB
chunkSize := 4 * 1024 * 1024
buffer := make([]byte, chunkSize)
segmentIndex := 0
bytesUploaded := int64(0)
for {
bytesRead, err := file.Read(buffer)
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("error reading file: %v", err)
}
finalUrl := MediaEndpoint + fmt.Sprintf("/%s/append", m.mediaID)
// Prepare form fields
formFields := map[string]string{
"segment_index": strconv.Itoa(segmentIndex),
}
requestOptions := RequestOptions{
Method: "POST",
Endpoint: finalUrl,
Headers: m.headers,
Data: "",
AuthType: m.authType,
Username: m.username,
Verbose: m.verbose,
Trace: m.trace,
}
multipartOptions := MultipartOptions{
RequestOptions: requestOptions,
FormFields: formFields,
FileField: "media",
FileName: filepath.Base(m.filePath),
FileData: buffer[:bytesRead],
}
// Send multipart request with buffer
_, clientErr := m.client.SendMultipartRequest(multipartOptions)
if clientErr != nil {
return fmt.Errorf("append request failed: %v", clientErr)
}
bytesUploaded += int64(bytesRead)
segmentIndex++
if m.verbose {
fmt.Printf("\033[33mUploaded %d of %d bytes (%.2f%%)\033[0m\n", bytesUploaded, m.fileSize, float64(bytesUploaded)/float64(m.fileSize)*100)
}
}
if m.verbose {
fmt.Printf("\033[32mUpload complete!\033[0m\n")
}
return nil
}
// Finalize finalizes the media upload
func (m *MediaUploader) Finalize() (json.RawMessage, error) {
if m.mediaID == "" {
return nil, fmt.Errorf("media ID not set, call Init first")
}
if m.verbose {
fmt.Printf("\033[32mFinalizing media upload...\033[0m\n")
}
finalUrl := MediaEndpoint + fmt.Sprintf("/%s/finalize", m.mediaID)
requestOptions := RequestOptions{
Method: "POST",
Endpoint: finalUrl,
Headers: m.headers,
Data: "",
AuthType: m.authType,
Username: m.username,
Verbose: m.verbose,
Trace: m.trace,
}
response, clientErr := m.client.SendRequest(requestOptions)
if clientErr != nil {
return nil, fmt.Errorf("finalize request failed: %v", clientErr)
}
return response, nil
}
// CheckStatus checks the status of the media upload
func (m *MediaUploader) CheckStatus() (json.RawMessage, error) {
if m.mediaID == "" {
return nil, fmt.Errorf("media ID not set, call Init first")
}
if m.verbose {
fmt.Println("Checking media status...")
}
url := MediaEndpoint + "?command=STATUS&media_id=" + m.mediaID
requestOptions := RequestOptions{
Method: "GET",
Endpoint: url,
Headers: []string{},
Data: "",
AuthType: m.authType,
Username: m.username,
Verbose: m.verbose,
Trace: m.trace,
}
response, clientErr := m.client.SendRequest(requestOptions)
if clientErr != nil {
return nil, fmt.Errorf("status request failed: %v", clientErr)
}
if m.verbose {
utils.FormatAndPrintResponse(response)
}
return response, nil
}
// WaitForProcessing waits for media processing to complete
func (m *MediaUploader) WaitForProcessing() (json.RawMessage, error) {
if m.mediaID == "" {
return nil, fmt.Errorf("media ID not set, call Init first")
}
if m.verbose {
fmt.Printf("\033[32mWaiting for media processing to complete...\033[0m\n")
}
for {
response, err := m.CheckStatus()
if err != nil {
return nil, err
}
var statusResponse struct {
Data struct {
ProcessingInfo struct {
State string `json:"state"`
CheckAfterSecs int `json:"check_after_secs"`
ProgressPercent int `json:"progress_percent"`
} `json:"processing_info"`
} `json:"data"`
}
if err := json.Unmarshal(response, &statusResponse); err != nil {
return nil, fmt.Errorf("failed to parse status response: %v", err)
}
state := statusResponse.Data.ProcessingInfo.State
if state == "succeeded" {
if m.verbose {
fmt.Printf("\033[32mMedia processing complete!\033[0m\n")
}
return response, nil
} else if state == "failed" {
return nil, fmt.Errorf("media processing failed")
}
checkAfterSecs := statusResponse.Data.ProcessingInfo.CheckAfterSecs
if checkAfterSecs <= 0 {
checkAfterSecs = 1
}
if m.verbose {
fmt.Printf("\033[33mMedia processing in progress (%d%%), checking again in %d seconds...\033[0m\n",
statusResponse.Data.ProcessingInfo.ProgressPercent,
checkAfterSecs)
}
time.Sleep(time.Duration(checkAfterSecs) * time.Second)
}
}
// GetMediaID returns the media ID
func (m *MediaUploader) GetMediaID() string {
return m.mediaID
}
// SetMediaID sets the media ID
func (m *MediaUploader) SetMediaID(mediaID string) {
m.mediaID = mediaID
}
// ExecuteMediaUpload handles the media upload command execution
func ExecuteMediaUpload(filePath, mediaType, mediaCategory, authType, username string, verbose, waitForProcessing, trace bool, headers []string, client Client) error {
uploader, err := NewMediaUploader(client, filePath, verbose, trace, authType, username, headers)
if err != nil {
return fmt.Errorf("error: %v", err)
}
if err := uploader.Init(mediaType, mediaCategory); err != nil {
return fmt.Errorf("error initializing upload: %v", err)
}
if err := uploader.Append(); err != nil {
return fmt.Errorf("error uploading media: %v", err)
}
finalizeResponse, err := uploader.Finalize()
if err != nil {
return fmt.Errorf("error finalizing upload: %v", err)
}
utils.FormatAndPrintResponse(finalizeResponse)
// Wait for processing if requested
if waitForProcessing && strings.Contains(mediaCategory, "video") {
processingResponse, err := uploader.WaitForProcessing()
if err != nil {
return fmt.Errorf("error during media processing: %v", err)
}
utils.FormatAndPrintResponse(processingResponse)
}
fmt.Printf("\033[32mMedia uploaded successfully! Media ID: %s\033[0m\n", uploader.GetMediaID())
return nil
}
// ExecuteMediaStatus handles the media status command execution
func ExecuteMediaStatus(mediaID, authType, username string, verbose, wait, trace bool, headers []string, client Client) error {
uploader := NewMediaUploaderWithoutFile(client, verbose, trace, authType, username, headers)
uploader.SetMediaID(mediaID)
if wait {
processingResponse, err := uploader.WaitForProcessing()
if err != nil {
return fmt.Errorf("error during media processing: %v", err)
}
prettyJSON, err := json.MarshalIndent(processingResponse, "", " ")
if err != nil {
return fmt.Errorf("error formatting JSON: %v", err)
}
fmt.Println(string(prettyJSON))
} else {
statusResponse, err := uploader.CheckStatus()
if err != nil {
return fmt.Errorf("error checking status: %v", err)
}
prettyJSON, err := json.MarshalIndent(statusResponse, "", " ")
if err != nil {
return fmt.Errorf("error formatting JSON: %v", err)
}
fmt.Println(string(prettyJSON))
}
return nil
}
// HandleMediaAppendRequest handles a media append request with a file
func HandleMediaAppendRequest(options RequestOptions, mediaFile string, client Client) (json.RawMessage, error) {
// TODO: This function is in a weird state since append accepts either a multipart request or a json request
// Right now, this function takes in segment_index from the json request and sends a multipart request
// We should refactor this to handle both cases (by adding curl-like multipart request support)
// example usage:
// xurl -X POST "/2/media/upload/{id}/append" \
// -H "Content-Type: multipart/form-data" \
// -F "media=@/path/to/your/file.mp4" \
// -F "segment_index=0"
mediaID := ExtractMediaID(options.Endpoint)
if mediaID == "" {
return nil, fmt.Errorf("media_id is required for append endpoint")
}
segmentIndex := ExtractSegmentIndex(options.Data)
if segmentIndex == "" {
segmentIndex = "0"
}
formFields := map[string]string{
"segment_index": segmentIndex,
}
multipartOptions := MultipartOptions{
RequestOptions: options,
FormFields: formFields,
FileField: "media",
FilePath: mediaFile,
FileName: filepath.Base(mediaFile),
FileData: []byte{},
}
response, clientErr := client.SendMultipartRequest(multipartOptions)
if clientErr != nil {
return nil, fmt.Errorf("append request failed: %v", clientErr)
}
return response, nil
}
// ExtractMediaID extracts media_id from URL or data
func ExtractMediaID(url string) string {
if url == "" {
return ""
}
if !strings.Contains(url, "/2/media/upload") {
return ""
}
if strings.HasSuffix(url, "/2/media/upload/initialize") {
return ""
}
// Extract media ID from path for append/finalize endpoints
if strings.Contains(url, "/2/media/upload/") {
parts := strings.Split(url, "/2/media/upload/")
if len(parts) > 1 {
path := parts[1]
for _, suffix := range []string{"/append", "/finalize"} {
if idx := strings.Index(path, suffix); idx != -1 {
return path[:idx]
}
}
}
}
if strings.Contains(url, "?") {
queryParams := strings.Split(url, "?")
if len(queryParams) > 1 {
params := strings.Split(queryParams[1], "&")
for _, param := range params {
if strings.HasPrefix(param, "media_id=") {
return strings.Split(param, "=")[1]
}
}
}
}
return ""
}
// extracts command from URL
func ExtractCommand(url string) string {
if strings.Contains(url, "/2/media/upload/") {
parts := strings.Split(url, "/2/media/upload/")
if len(parts) > 1 {
path := parts[1]
if strings.Contains(path, "/append") {
return "append"
}
if strings.Contains(path, "/finalize") {
return "finalize"
}
if path == "initialize" {
return "initialize"
}
}
return "status"
}
return ""
}
// ExtractSegmentIndex extracts segment_index from URL or data
func ExtractSegmentIndex(data string) string {
var jsonData map[string]string
if err := json.Unmarshal([]byte(data), &jsonData); err == nil {
if segmentIndex, ok := jsonData["segment_index"]; ok {
return segmentIndex
}
}
return ""
}
// IsMediaAppendRequest checks if the request is a media append request
func IsMediaAppendRequest(url string, mediaFile string) bool {
return strings.Contains(url, "/2/media/upload") &&
strings.Contains(url, "append") &&
mediaFile != ""
}
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/xdevplatform/xurl/config"
)
// ---------------------------------------------------------------
// Pure‑function unit tests
// ---------------------------------------------------------------
func TestResolvePostID(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{"bare ID", "1234567890", "1234567890"},
{"x.com URL", "https://x.com/user/status/1234567890", "1234567890"},
{"legacy domain URL", "https://twitter.com/user/status/9876543210", "9876543210"},
{"URL with query params", "https://x.com/user/status/111?s=20", "111"},
{"ID with whitespace", " 1234567890 ", "1234567890"},
{"URL without status segment", "https://x.com/user", "https://x.com/user"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ResolvePostID(tt.input)
assert.Equal(t, tt.want, got)
})
}
}
func TestResolveUsername(t *testing.T) {
tests := []struct {
input string
want string
}{
{"elonmusk", "elonmusk"},
{"@elonmusk", "elonmusk"},
{" @XDev ", "XDev"},
{"plain", "plain"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := ResolveUsername(tt.input)
assert.Equal(t, tt.want, got)
})
}
}
// ---------------------------------------------------------------
// Integration tests using httptest
// ---------------------------------------------------------------
func setupShortcutServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
// POST /2/tweets — create post
case r.URL.Path == "/2/tweets" && r.Method == "POST":
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"data":{"id":"99999","text":"Hello!"}}`))
// DELETE /2/tweets/:id — delete post
case r.Method == "DELETE" && strings.HasPrefix(r.URL.Path, "/2/tweets/"):
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":{"deleted":true}}`))
// GET /2/tweets/search/recent — search posts
case strings.HasPrefix(r.URL.Path, "/2/tweets/search/recent") && r.Method == "GET":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":[{"id":"1","text":"result one"}],"meta":{"result_count":1}}`))
// GET /2/tweets/:id — read post
case strings.HasPrefix(r.URL.Path, "/2/tweets/") && r.Method == "GET":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":{"id":"123","text":"existing post","public_metrics":{"like_count":5}}}`))
// GET /2/users/me
case r.URL.Path == "/2/users/me" && r.Method == "GET":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":{"id":"42","username":"testbot","name":"Test Bot"}}`))
// GET /2/users/by/username/:username
case strings.HasPrefix(r.URL.Path, "/2/users/by/username/"):
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":{"id":"100","username":"lookedup","name":"Looked Up"}}`))
default:
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"data":{}}`))
}
}))
}
func shortcutClient(t *testing.T, server *httptest.Server) *ApiClient {
authMock, tempDir := createMockAuth(t)
t.Cleanup(func() { os.RemoveAll(tempDir) })
cfg := &config.Config{APIBaseURL: server.URL}
return NewApiClient(cfg, authMock)
}
func baseTestOpts() RequestOptions {
return RequestOptions{Verbose: false}
}
// ---- CreatePost ----
func TestCreatePost(t *testing.T) {
server := setupShortcutServer()
defer server.Close()
client := shortcutClient(t, server)
resp, err := CreatePost(client, "Hello!", nil, baseTestOpts())
require.NoError(t, err)
var result struct {
Data struct {
ID string `json:"id"`
Text string `json:"text"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(resp, &result))
assert.Equal(t, "99999", result.Data.ID)
assert.Equal(t, "Hello!", result.Data.Text)
}
func TestCreatePostWithMedia(t *testing.T) {
server := setupShortcutServer()
defer server.Close()
client := shortcutClient(t, server)
resp, err := CreatePost(client, "With media", []string{"m1", "m2"}, baseTestOpts())
require.NoError(t, err)
assert.NotNil(t, resp)
}
// ---- ReplyToPost ----
func TestReplyToPost(t *testing.T) {
server := setupShortcutServer()
defer server.Close()
client := shortcutClient(t, server)
resp, err := ReplyToPost(client, "123", "nice!", nil, baseTestOpts())
require.NoError(t, err)
assert.NotNil(t, resp)
}
func TestReplyToPostWithURL(t *testing.T) {
server := setupShortcutServer()
defer server.Close()
client := shortcutClient(t, server)
resp, err := ReplyToPost(client, "https://x.com/u/status/123", "nice!", nil, baseTestOpts())
require.NoError(t, err)
assert.NotNil(t, resp)
}
// ---- QuotePost ----
func TestQuotePost(t *testing.T) {
server := setupShortcutServer()
defer server.Close()
client := shortcutClient(t, server)
resp, err := QuotePost(client, "123", "my take", baseTestOpts())
require.NoError(t, err)
assert.NotNil(t, resp)
}
// ---- DeletePost ----
func TestDeletePost(t *testing.T) {
server := setupShortcutServer()
defer server.Close()
client := shortcutClient(t, server)
resp, err := DeletePost(client, "123", baseTestOpts())
require.NoError(t, err)
var result struct {
Data struct {
Deleted bool `json:"deleted"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(resp, &result))
assert.True(t, result.Data.Deleted)
}
// ---- ReadPost ----
func TestReadPost(t *testing.T) {
server := setupShortcutServer()
defer server.Close()
client := shortcutClient(t, server)
resp, err := ReadPost(client, "123", baseTestOpts())
require.NoError(t, err)
var result struct {
Data struct {
ID string `json:"id"`
Text string `json:"text"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(resp, &result))
assert.Equal(t, "123", result.Data.ID)
}
// ---- SearchPosts ----
func TestSearchPosts(t *testing.T) {
server := setupShortcutServer()
defer server.Close()
client := shortcutClient(t, server)
resp, err := SearchPosts(client, "golang", 10, baseTestOpts())
require.NoError(t, err)
var result struct {
Meta struct {
ResultCount int `json:"result_count"`
} `json:"meta"`
}
require.NoError(t, json.Unmarshal(resp, &result))
assert.Equal(t, 1, result.Meta.ResultCount)
}
// ---- GetMe ----
func TestGetMe(t *testing.T) {
server := setupShortcutServer()
defer server.Close()
client := shortcutClient(t, server)
resp, err := GetMe(client, baseTestOpts())
require.NoError(t, err)
var result struct {
Data struct {
ID string `json:"id"`
Username string `json:"username"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(resp, &result))
assert.Equal(t, "42", result.Data.ID)
assert.Equal(t, "testbot", result.Data.Username)
}
// ---- LookupUser ----
func TestLookupUser(t *testing.T) {
server := setupShortcutServer()
defer server.Close()
client := shortcutClient(t, server)
resp, err := LookupUser(client, "@someuser", baseTestOpts())
require.NoError(t, err)
var result struct {
Data struct {
ID string `json:"id"`
Username string `json:"username"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(resp, &result))
assert.Equal(t, "100", result.Data.ID)
assert.Equal(t, "lookedup", result.Data.Username)
}
package api
import (
"encoding/json"
"fmt"
"net/url"
"strings"
)
// ------------------------------------------------
// Request‑body helpers for the X API v2 shortcuts
// ------------------------------------------------
// PostBody is the JSON body for POST /2/tweets
type PostBody struct {
Text string `json:"text"`
Reply *PostReply `json:"reply,omitempty"`
Quote *string `json:"quote_tweet_id,omitempty"` // API field name — do not rename
Media *PostMedia `json:"media,omitempty"`
Poll *PostPoll `json:"poll,omitempty"`
}
// PostReply nests inside PostBody for replies
type PostReply struct {
InReplyToPostID string `json:"in_reply_to_tweet_id"` // API field name — do not rename
}
// PostMedia nests inside PostBody to attach uploaded media
type PostMedia struct {
MediaIDs []string `json:"media_ids"`
}
// PostPoll nests inside PostBody to create a poll
type PostPoll struct {
Options []string `json:"options"`
DurationMinutes int `json:"duration_minutes"`
}
// ------------------------------------------------
// Helpers
// ------------------------------------------------
// ResolvePostID extracts a post ID from a full URL or returns the input as‑is.
// Accepts:
// - https://x.com/user/status/123456
// - https://x.com/user/status/123456 (legacy domain also works)
// - 123456
func ResolvePostID(input string) string {
input = strings.TrimSpace(input)
// If it looks like a URL, pull the last path segment after "status"
if strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "https://") {
parsed, err := url.Parse(input)
if err == nil {
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
for i, p := range parts {
if p == "status" && i+1 < len(parts) {
return parts[i+1]
}
}
}
}
return input
}
// ResolveUsername normalises a username – strips a leading "@" if present.
func ResolveUsername(input string) string {
return strings.TrimPrefix(strings.TrimSpace(input), "@")
}
// ------------------------------------------------
// Shortcut executors
// ------------------------------------------------
// CreatePost sends a new post and returns the API response.
func CreatePost(client Client, text string, mediaIDs []string, opts RequestOptions) (json.RawMessage, error) {
body := PostBody{Text: text}
if len(mediaIDs) > 0 {
body.Media = &PostMedia{MediaIDs: mediaIDs}
}
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal post body: %w", err)
}
opts.Method = "POST"
opts.Endpoint = "/2/tweets"
opts.Data = string(data)
return client.SendRequest(opts)
}
// ReplyToPost sends a reply to an existing post.
func ReplyToPost(client Client, postID, text string, mediaIDs []string, opts RequestOptions) (json.RawMessage, error) {
postID = ResolvePostID(postID)
body := PostBody{
Text: text,
Reply: &PostReply{InReplyToPostID: postID},
}
if len(mediaIDs) > 0 {
body.Media = &PostMedia{MediaIDs: mediaIDs}
}
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal reply body: %w", err)
}
opts.Method = "POST"
opts.Endpoint = "/2/tweets"
opts.Data = string(data)
return client.SendRequest(opts)
}
// QuotePost sends a quote post.
func QuotePost(client Client, postID, text string, opts RequestOptions) (json.RawMessage, error) {
postID = ResolvePostID(postID)
body := PostBody{
Text: text,
Quote: &postID,
}
data, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal quote body: %w", err)
}
opts.Method = "POST"
opts.Endpoint = "/2/tweets"
opts.Data = string(data)
return client.SendRequest(opts)
}
// DeletePost deletes a post by ID.
func DeletePost(client Client, postID string, opts RequestOptions) (json.RawMessage, error) {
postID = ResolvePostID(postID)
opts.Method = "DELETE"
opts.Endpoint = fmt.Sprintf("/2/tweets/%s", postID)
opts.Data = ""
return client.SendRequest(opts)
}
// ReadPost fetches a single post with useful expansions.
func ReadPost(client Client, postID string, opts RequestOptions) (json.RawMessage, error) {
postID = ResolvePostID(postID)
opts.Method = "GET"
opts.Endpoint = fmt.Sprintf("/2/tweets/%s?tweet.fields=created_at,public_metrics,conversation_id,in_reply_to_user_id,referenced_tweets,entities,attachments&expansions=author_id,referenced_tweets.id&user.fields=username,name,verified", postID)
opts.Data = ""
return client.SendRequest(opts)
}
// SearchPosts searches recent posts.
func SearchPosts(client Client, query string, maxResults int, opts RequestOptions) (json.RawMessage, error) {
q := url.QueryEscape(query)
// X API enforces min 10 / max 100 for search
if maxResults < 10 {
maxResults = 10
} else if maxResults > 100 {
maxResults = 100
}
opts.Method = "GET"
opts.Endpoint = fmt.Sprintf("/2/tweets/search/recent?query=%s&max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=author_id&user.fields=username,name,verified", q, maxResults)
opts.Data = ""
return client.SendRequest(opts)
}
// GetMe fetches the authenticated user's profile.
func GetMe(client Client, opts RequestOptions) (json.RawMessage, error) {
opts.Method = "GET"
opts.Endpoint = "/2/users/me?user.fields=created_at,description,public_metrics,verified,profile_image_url"
opts.Data = ""
return client.SendRequest(opts)
}
// LookupUser fetches a user by username.
func LookupUser(client Client, username string, opts RequestOptions) (json.RawMessage, error) {
username = ResolveUsername(username)
opts.Method = "GET"
opts.Endpoint = fmt.Sprintf("/2/users/by/username/%s?user.fields=created_at,description,public_metrics,verified,profile_image_url", username)
opts.Data = ""
return client.SendRequest(opts)
}
// GetUserPosts fetches recent posts by a user ID.
func GetUserPosts(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) {
opts.Method = "GET"
opts.Endpoint = fmt.Sprintf("/2/users/%s/tweets?max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=referenced_tweets.id", userID, maxResults)
opts.Data = ""
return client.SendRequest(opts)
}
// GetTimeline fetches the authenticated user's reverse‑chronological timeline.
// Route: GET /2/users/{id}/timelines/reverse_chronological
func GetTimeline(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) {
opts.Method = "GET"
opts.Endpoint = fmt.Sprintf("/2/users/%s/timelines/reverse_chronological?max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=author_id&user.fields=username,name", userID, maxResults)
opts.Data = ""
return client.SendRequest(opts)
}
// GetMentions fetches recent mentions for a user.
func GetMentions(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) {
opts.Method = "GET"
opts.Endpoint = fmt.Sprintf("/2/users/%s/mentions?max_results=%d&tweet.fields=created_at,public_metrics,conversation_id,entities&expansions=author_id&user.fields=username,name", userID, maxResults)
opts.Data = ""
return client.SendRequest(opts)
}
// LikePost likes a post on behalf of the authenticated user.
func LikePost(client Client, userID, postID string, opts RequestOptions) (json.RawMessage, error) {
postID = ResolvePostID(postID)
body := fmt.Sprintf(`{"tweet_id":"%s"}`, postID) // API field name — do not rename
opts.Method = "POST"
opts.Endpoint = fmt.Sprintf("/2/users/%s/likes", userID)
opts.Data = body
return client.SendRequest(opts)
}
// UnlikePost unlikes a post.
func UnlikePost(client Client, userID, postID string, opts RequestOptions) (json.RawMessage, error) {
postID = ResolvePostID(postID)
opts.Method = "DELETE"
opts.Endpoint = fmt.Sprintf("/2/users/%s/likes/%s", userID, postID)
opts.Data = ""
return client.SendRequest(opts)
}
// Repost reposts a post.
func Repost(client Client, userID, postID string, opts RequestOptions) (json.RawMessage, error) {
postID = ResolvePostID(postID)
body := fmt.Sprintf(`{"tweet_id":"%s"}`, postID) // API field name — do not rename
opts.Method = "POST"
opts.Endpoint = fmt.Sprintf("/2/users/%s/retweets", userID)
opts.Data = body
return client.SendRequest(opts)
}
// Unrepost removes a repost.
func Unrepost(client Client, userID, postID string, opts RequestOptions) (json.RawMessage, error) {
postID = ResolvePostID(postID)
opts.Method = "DELETE"
opts.Endpoint = fmt.Sprintf("/2/users/%s/retweets/%s", userID, postID)
opts.Data = ""
return client.SendRequest(opts)
}
// Bookmark bookmarks a post.
func Bookmark(client Client, userID, postID string, opts RequestOptions) (json.RawMessage, error) {
postID = ResolvePostID(postID)
body := fmt.Sprintf(`{"tweet_id":"%s"}`, postID) // API field name — do not rename
opts.Method = "POST"
opts.Endpoint = fmt.Sprintf("/2/users/%s/bookmarks", userID)
opts.Data = body
return client.SendRequest(opts)
}
// Unbookmark removes a bookmark.
func Unbookmark(client Client, userID, postID string, opts RequestOptions) (json.RawMessage, error) {
postID = ResolvePostID(postID)
opts.Method = "DELETE"
opts.Endpoint = fmt.Sprintf("/2/users/%s/bookmarks/%s", userID, postID)
opts.Data = ""
return client.SendRequest(opts)
}
// GetBookmarks fetches the authenticated user's bookmarks.
func GetBookmarks(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) {
opts.Method = "GET"
opts.Endpoint = fmt.Sprintf("/2/users/%s/bookmarks?max_results=%d&tweet.fields=created_at,public_metrics,entities&expansions=author_id&user.fields=username,name", userID, maxResults)
opts.Data = ""
return client.SendRequest(opts)
}
// FollowUser follows a user.
func FollowUser(client Client, sourceUserID, targetUserID string, opts RequestOptions) (json.RawMessage, error) {
body := fmt.Sprintf(`{"target_user_id":"%s"}`, targetUserID)
opts.Method = "POST"
opts.Endpoint = fmt.Sprintf("/2/users/%s/following", sourceUserID)
opts.Data = body
return client.SendRequest(opts)
}
// UnfollowUser unfollows a user.
func UnfollowUser(client Client, sourceUserID, targetUserID string, opts RequestOptions) (json.RawMessage, error) {
opts.Method = "DELETE"
opts.Endpoint = fmt.Sprintf("/2/users/%s/following/%s", sourceUserID, targetUserID)
opts.Data = ""
return client.SendRequest(opts)
}
// GetFollowing fetches users that a given user follows.
func GetFollowing(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) {
opts.Method = "GET"
opts.Endpoint = fmt.Sprintf("/2/users/%s/following?max_results=%d&user.fields=created_at,description,public_metrics,verified", userID, maxResults)
opts.Data = ""
return client.SendRequest(opts)
}
// GetFollowers fetches followers of a given user.
func GetFollowers(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) {
opts.Method = "GET"
opts.Endpoint = fmt.Sprintf("/2/users/%s/followers?max_results=%d&user.fields=created_at,description,public_metrics,verified", userID, maxResults)
opts.Data = ""
return client.SendRequest(opts)
}
// SendDM sends a direct message to a user.
func SendDM(client Client, participantID, text string, opts RequestOptions) (json.RawMessage, error) {
body := fmt.Sprintf(`{"text":"%s"}`, strings.ReplaceAll(text, `"`, `\"`))
opts.Method = "POST"
opts.Endpoint = fmt.Sprintf("/2/dm_conversations/with/%s/messages", participantID)
opts.Data = body
return client.SendRequest(opts)
}
// GetDMEvents fetches recent DM events.
func GetDMEvents(client Client, maxResults int, opts RequestOptions) (json.RawMessage, error) {
opts.Method = "GET"
opts.Endpoint = fmt.Sprintf("/2/dm_events?max_results=%d&dm_event.fields=created_at,dm_conversation_id,sender_id,text&expansions=sender_id&user.fields=username,name", maxResults)
opts.Data = ""
return client.SendRequest(opts)
}
// GetLikedPosts fetches posts liked by a user.
func GetLikedPosts(client Client, userID string, maxResults int, opts RequestOptions) (json.RawMessage, error) {
opts.Method = "GET"
opts.Endpoint = fmt.Sprintf("/2/users/%s/liked_tweets?max_results=%d&tweet.fields=created_at,public_metrics,entities&expansions=author_id&user.fields=username,name", userID, maxResults)
opts.Data = ""
return client.SendRequest(opts)
}
// BlockUser blocks a user.
func BlockUser(client Client, sourceUserID, targetUserID string, opts RequestOptions) (json.RawMessage, error) {
body := fmt.Sprintf(`{"target_user_id":"%s"}`, targetUserID)
opts.Method = "POST"
opts.Endpoint = fmt.Sprintf("/2/users/%s/blocking", sourceUserID)
opts.Data = body
return client.SendRequest(opts)
}
// UnblockUser unblocks a user.
func UnblockUser(client Client, sourceUserID, targetUserID string, opts RequestOptions) (json.RawMessage, error) {
opts.Method = "DELETE"
opts.Endpoint = fmt.Sprintf("/2/users/%s/blocking/%s", sourceUserID, targetUserID)
opts.Data = ""
return client.SendRequest(opts)
}
// MuteUser mutes a user.
func MuteUser(client Client, sourceUserID, targetUserID string, opts RequestOptions) (json.RawMessage, error) {
body := fmt.Sprintf(`{"target_user_id":"%s"}`, targetUserID)
opts.Method = "POST"
opts.Endpoint = fmt.Sprintf("/2/users/%s/muting", sourceUserID)
opts.Data = body
return client.SendRequest(opts)
}
// UnmuteUser unmutes a user.
func UnmuteUser(client Client, sourceUserID, targetUserID string, opts RequestOptions) (json.RawMessage, error) {
opts.Method = "DELETE"
opts.Endpoint = fmt.Sprintf("/2/users/%s/muting/%s", sourceUserID, targetUserID)
opts.Data = ""
return client.SendRequest(opts)
}
package auth
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/xdevplatform/xurl/config"
"github.com/xdevplatform/xurl/store"
)
// Helper function to create a temporary token store for testing
func createTempTokenStore(t *testing.T) (*store.TokenStore, string) {
// Create a temporary directory for testing
tempDir, err := os.MkdirTemp("", "xurl_test")
if err != nil {
t.Fatalf("Failed to create temp directory: %v", err)
}
// Create a token store with a file in the temp directory
tempFile := filepath.Join(tempDir, ".xurl")
ts := &store.TokenStore{
Apps: make(map[string]*store.App),
DefaultApp: "default",
FilePath: tempFile,
}
ts.Apps["default"] = &store.App{
OAuth2Tokens: make(map[string]store.Token),
}
return ts, tempDir
}
func TestNewAuth(t *testing.T) {
cfg := &config.Config{
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
RedirectURI: "http://localhost:8080/callback",
AuthURL: "https://x.com/i/oauth2/authorize",
TokenURL: "https://api.x.com/2/oauth2/token",
APIBaseURL: "https://api.x.com",
InfoURL: "https://api.x.com/2/users/me",
}
auth := NewAuth(cfg)
require.NotNil(t, auth, "Expected non-nil Auth")
assert.NotNil(t, auth.TokenStore, "Expected non-nil TokenStore")
}
func TestWithTokenStore(t *testing.T) {
cfg := &config.Config{
ClientID: "test-client-id",
ClientSecret: "test-client-secret",
RedirectURI: "http://localhost:8080/callback",
AuthURL: "https://x.com/i/oauth2/authorize",
TokenURL: "https://api.x.com/2/oauth2/token",
APIBaseURL: "https://api.x.com",
InfoURL: "https://api.x.com/2/users/me",
}
auth := NewAuth(cfg)
tokenStore, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
newAuth := auth.WithTokenStore(tokenStore)
require.NotNil(t, newAuth, "Expected non-nil Auth")
assert.Equal(t, tokenStore, newAuth.TokenStore, "Expected TokenStore to be set to the provided TokenStore")
}
func TestBearerToken(t *testing.T) {
cfg := &config.Config{}
auth := NewAuth(cfg)
tokenStore, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
auth = auth.WithTokenStore(tokenStore)
// Test with no bearer token
_, err := auth.GetBearerTokenHeader()
assert.Error(t, err, "Expected error when no bearer token is set")
// Test with bearer token
err = tokenStore.SaveBearerToken("test-bearer-token")
require.NoError(t, err, "Failed to save bearer token")
token, err := auth.GetBearerTokenHeader()
require.NoError(t, err, "Failed to get bearer token")
assert.Equal(t, "Bearer test-bearer-token", token, "Expected correct bearer token format")
}
func TestGenerateNonce(t *testing.T) {
nonce1 := generateNonce()
nonce2 := generateNonce()
assert.NotEmpty(t, nonce1, "Expected non-empty nonce")
assert.NotEqual(t, nonce1, nonce2, "Expected different nonces")
}
func TestGenerateTimestamp(t *testing.T) {
timestamp := generateTimestamp()
assert.NotEmpty(t, timestamp, "Expected non-empty timestamp")
for _, c := range timestamp {
assert.True(t, c >= '0' && c <= '9', "Expected timestamp to contain only digits, got %s", timestamp)
}
}
func TestEncode(t *testing.T) {
testCases := []struct {
input string
expected string
}{
{"abc", "abc"},
{"a b c", "a+b+c"},
{"a+b+c", "a%2Bb%2Bc"},
{"a/b/c", "a%2Fb%2Fc"},
{"a?b=c", "a%3Fb%3Dc"},
{"a&b=c", "a%26b%3Dc"},
}
for _, tc := range testCases {
t.Run(tc.input, func(t *testing.T) {
result := encode(tc.input)
assert.Equal(t, tc.expected, result, "encode(%q) should return %q", tc.input, result)
})
}
}
func TestGenerateCodeVerifierAndChallenge(t *testing.T) {
verifier, challenge := generateCodeVerifierAndChallenge()
assert.NotEmpty(t, verifier, "Expected non-empty verifier")
assert.NotEmpty(t, challenge, "Expected non-empty challenge")
assert.NotEqual(t, verifier, challenge, "Expected verifier and challenge to be different")
}
func TestGetOAuth2Scopes(t *testing.T) {
scopes := getOAuth2Scopes()
assert.NotEmpty(t, scopes, "Expected non-empty scopes")
// Check for some common scopes
assert.Contains(t, scopes, "tweet.read", "Expected 'tweet.read' scope")
assert.Contains(t, scopes, "users.read", "Expected 'users.read' scope")
}
func TestCredentialResolutionPriority(t *testing.T) {
tokenStore, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
// Store has credentials in the default app
tokenStore.Apps["default"].ClientID = "store-id"
tokenStore.Apps["default"].ClientSecret = "store-secret"
tokenStore.SaveBearerToken("x") // force save
t.Run("Env vars take priority over store", func(t *testing.T) {
cfg := &config.Config{
ClientID: "env-id",
ClientSecret: "env-secret",
}
a := NewAuth(cfg).WithTokenStore(tokenStore)
assert.Equal(t, "env-id", a.clientID)
assert.Equal(t, "env-secret", a.clientSecret)
})
t.Run("Store used when env vars empty", func(t *testing.T) {
// Simulate what NewAuth does when env vars are empty:
// it should fall back to the store's app credentials.
a := &Auth{
TokenStore: tokenStore,
}
app := tokenStore.ResolveApp("")
a.clientID = app.ClientID
a.clientSecret = app.ClientSecret
assert.Equal(t, "store-id", a.clientID)
assert.Equal(t, "store-secret", a.clientSecret)
})
}
func TestWithAppName(t *testing.T) {
tempDir, err := os.MkdirTemp("", "xurl_auth_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
t.Setenv("HOME", tempDir)
tokenStore, tsDir := createTempTokenStore(t)
defer os.RemoveAll(tsDir)
// Add a second app with different credentials
tokenStore.AddApp("other", "other-id", "other-secret")
err = tokenStore.SetAppRedirectURI("other", "http://localhost:9090/callback")
require.NoError(t, err)
cfg := &config.Config{}
a := NewAuth(cfg).WithTokenStore(tokenStore)
// Initially no app override — clientID/secret are empty (no env vars, default app has none)
assert.Empty(t, a.clientID)
// Set app name — should pick up other app's credentials
a.WithAppName("other")
assert.Equal(t, "other-id", a.clientID)
assert.Equal(t, "other-secret", a.clientSecret)
assert.Equal(t, "http://localhost:9090/callback", a.redirectURI)
}
func TestWithAppNameOverridesEnvCredentials(t *testing.T) {
tempDir, err := os.MkdirTemp("", "xurl_auth_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
t.Setenv("HOME", tempDir)
tokenStore, tsDir := createTempTokenStore(t)
defer os.RemoveAll(tsDir)
tokenStore.AddApp("my-app", "app-id", "app-secret")
err = tokenStore.SetAppRedirectURI("my-app", "http://localhost:9090/callback")
require.NoError(t, err)
// Simulate env vars being set at startup
cfg := &config.Config{
ClientID: "env-id",
ClientSecret: "env-secret",
RedirectURI: "http://127.0.0.1:7777/callback",
RedirectURIFromEnv: true,
}
a := NewAuth(cfg).WithTokenStore(tokenStore)
assert.Equal(t, "env-id", a.clientID)
// --app override should replace env-var credentials with the named app's
a.WithAppName("my-app")
assert.Equal(t, "app-id", a.clientID)
assert.Equal(t, "app-secret", a.clientSecret)
assert.Equal(t, "http://127.0.0.1:7777/callback", a.redirectURI)
}
func TestAppFlagTokenIsolation(t *testing.T) {
tempDir, err := os.MkdirTemp("", "xurl_auth_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
t.Setenv("HOME", tempDir)
tokenStore, tsDir := createTempTokenStore(t)
defer os.RemoveAll(tsDir)
tokenStore.AddApp("app-a", "id-a", "secret-a")
tokenStore.AddApp("app-b", "id-b", "secret-b")
// Save a bearer token only in app-a
tokenStore.SaveBearerTokenForApp("app-a", "bearer-for-a")
// Save OAuth1 tokens only in app-b
tokenStore.SaveOAuth1TokensForApp("app-b", "at-b", "ts-b", "ck-b", "cs-b")
// Save OAuth2 token only in app-a
tokenStore.SaveOAuth2TokenForApp("app-a", "alice", "oauth2-for-a", "refresh-a", 9999999999)
t.Run("Bearer token from named app", func(t *testing.T) {
cfg := &config.Config{}
a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("app-a")
header, err := a.GetBearerTokenHeader()
require.NoError(t, err)
assert.Equal(t, "Bearer bearer-for-a", header)
})
t.Run("Bearer token not found in other app", func(t *testing.T) {
cfg := &config.Config{}
a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("app-b")
_, err := a.GetBearerTokenHeader()
assert.Error(t, err, "app-b has no bearer token, expected error")
})
t.Run("OAuth1 header from named app", func(t *testing.T) {
cfg := &config.Config{}
a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("app-b")
header, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil)
require.NoError(t, err)
assert.Contains(t, header, "OAuth ")
})
t.Run("OAuth1 not found in other app", func(t *testing.T) {
cfg := &config.Config{}
a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("app-a")
_, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil)
assert.Error(t, err, "app-a has no OAuth1 token, expected error")
})
t.Run("Default app used when no --app flag", func(t *testing.T) {
tokenStore.SetDefaultApp("app-a")
cfg := &config.Config{}
// No WithAppName call — appName stays ""
a := NewAuth(cfg).WithTokenStore(tokenStore)
header, err := a.GetBearerTokenHeader()
require.NoError(t, err)
assert.Equal(t, "Bearer bearer-for-a", header)
})
}
func TestWithAppNameNonexistent(t *testing.T) {
tempDir, err := os.MkdirTemp("", "xurl_auth_test")
require.NoError(t, err)
defer os.RemoveAll(tempDir)
t.Setenv("HOME", tempDir)
tokenStore, tsDir := createTempTokenStore(t)
defer os.RemoveAll(tsDir)
cfg := &config.Config{}
a := NewAuth(cfg).WithTokenStore(tokenStore)
// Setting a nonexistent app name should not panic
a.WithAppName("doesnt-exist")
// Should fall through to default app (which has empty creds)
assert.Empty(t, a.clientID)
}
func TestOAuth1HeaderWithTokenStore(t *testing.T) {
tokenStore, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
cfg := &config.Config{}
a := NewAuth(cfg).WithTokenStore(tokenStore)
// No OAuth1 token — should fail
_, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil)
assert.Error(t, err)
// Save OAuth1 token and try again
tokenStore.SaveOAuth1Tokens("at", "ts", "ck", "cs")
header, err := a.GetOAuth1Header("GET", "https://api.x.com/2/users/me", nil)
require.NoError(t, err)
assert.Contains(t, header, "OAuth ")
assert.Contains(t, header, "oauth_consumer_key")
}
func TestGetOAuth2HeaderNoToken(t *testing.T) {
tokenStore, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
cfg := &config.Config{
ClientID: "test-id",
ClientSecret: "test-secret",
AuthURL: "https://x.com/i/oauth2/authorize",
TokenURL: "https://api.x.com/2/oauth2/token",
RedirectURI: "http://localhost:8080/callback",
InfoURL: "https://api.x.com/2/users/me",
}
_ = NewAuth(cfg).WithTokenStore(tokenStore)
// Verify that looking up a nonexistent user returns nil
token := tokenStore.GetOAuth2Token("nobody")
assert.Nil(t, token)
}
// mockTokenServer returns an httptest.Server that responds to token refresh
// requests with a new access token.
func mockTokenServer(t *testing.T, accessToken, refreshToken string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": refreshToken,
})
}))
}
func TestRefreshOAuth2TokenSavesToNamedApp(t *testing.T) {
server := mockTokenServer(t, "new-access-token", "new-refresh-token")
defer server.Close()
tokenStore, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
tokenStore.AddApp("my-app", "client-id", "client-secret")
// Save an already-expired token to "my-app"
expiredTime := uint64(time.Now().Add(-1 * time.Hour).Unix())
tokenStore.SaveOAuth2TokenForApp("my-app", "alice", "old-access", "old-refresh", expiredTime)
cfg := &config.Config{TokenURL: server.URL + "/token"}
a := NewAuth(cfg).WithTokenStore(tokenStore).WithAppName("my-app")
newToken, err := a.RefreshOAuth2Token("alice")
require.NoError(t, err)
assert.Equal(t, "new-access-token", newToken)
// Refreshed token must be saved to "my-app", not the default app
tok := tokenStore.GetOAuth2TokenForApp("my-app", "alice")
require.NotNil(t, tok)
assert.Equal(t, "new-access-token", tok.OAuth2.AccessToken)
// Default app must not have received the token
assert.Nil(t, tokenStore.GetOAuth2TokenForApp("default", "alice"))
}
func TestRefreshOAuth2TokenSavesToDefaultAppWhenNoOverride(t *testing.T) {
server := mockTokenServer(t, "new-access-token", "new-refresh-token")
defer server.Close()
tokenStore, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
tokenStore.Apps["default"].ClientID = "client-id"
tokenStore.Apps["default"].ClientSecret = "client-secret"
// Save an expired token to the default app
expiredTime := uint64(time.Now().Add(-1 * time.Hour).Unix())
tokenStore.SaveOAuth2TokenForApp("default", "bob", "old-access", "old-refresh", expiredTime)
cfg := &config.Config{TokenURL: server.URL + "/token"}
// No WithAppName — appName stays ""
a := NewAuth(cfg).WithTokenStore(tokenStore)
newToken, err := a.RefreshOAuth2Token("bob")
require.NoError(t, err)
assert.Equal(t, "new-access-token", newToken)
// Token must be saved back to the default app
tok := tokenStore.GetOAuth2TokenForApp("default", "bob")
require.NotNil(t, tok)
assert.Equal(t, "new-access-token", tok.OAuth2.AccessToken)
}
func TestBrowserLaunchCommand(t *testing.T) {
url := "https://x.com/i/oauth2/authorize?client_id=abc&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&response_type=code&scope=tweet.read+users.read&state=123&code_challenge=xyz&code_challenge_method=S256"
t.Run("windows keeps the full oauth url as a single argument", func(t *testing.T) {
cmd, args := browserLaunchCommand("windows", url)
assert.Equal(t, "rundll32", cmd)
assert.Equal(t, []string{"url.dll,FileProtocolHandler", url}, args)
})
t.Run("darwin uses open", func(t *testing.T) {
cmd, args := browserLaunchCommand("darwin", url)
assert.Equal(t, "open", cmd)
assert.Equal(t, []string{url}, args)
})
t.Run("linux uses xdg-open", func(t *testing.T) {
cmd, args := browserLaunchCommand("linux", url)
assert.Equal(t, "xdg-open", cmd)
assert.Equal(t, []string{url}, args)
})
}
func TestListenerConfigFromRedirectURI(t *testing.T) {
testCases := []struct {
name string
redirectURI string
wantAddresses []string
wantCallback string
}{
{
name: "localhost redirect listens on both loopback families",
redirectURI: "http://localhost:8080/callback",
wantAddresses: []string{"127.0.0.1:8080", "[::1]:8080"},
wantCallback: "/callback",
},
{
name: "ipv4 loopback redirect uses configured host and port",
redirectURI: "http://127.0.0.1:9090/oauth/callback",
wantAddresses: []string{"127.0.0.1:9090"},
wantCallback: "/oauth/callback",
},
{
name: "missing host and port fall back safely",
redirectURI: "/callback",
wantAddresses: []string{"127.0.0.1:8080", "[::1]:8080"},
wantCallback: "/callback",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
config, err := listenerConfigFromRedirectURI(tc.redirectURI)
require.NoError(t, err)
assert.Equal(t, tc.wantAddresses, config.Addresses)
assert.Equal(t, tc.wantCallback, config.CallbackPath)
})
}
}
func TestRefreshOAuth2TokenPreservesUnnamedTokenWhenUsernameLookupFails(t *testing.T) {
tokenServer := mockTokenServer(t, "new-access-token", "new-refresh-token")
defer tokenServer.Close()
infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{},
})
}))
defer infoServer.Close()
tokenStore, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
expiredTime := uint64(time.Now().Add(-1 * time.Hour).Unix())
err := tokenStore.SaveOAuth2TokenForApp("default", "", "old-access", "old-refresh", expiredTime)
require.NoError(t, err)
cfg := &config.Config{
TokenURL: serverURL(tokenServer, "/token"),
InfoURL: infoServer.URL,
}
a := NewAuth(cfg).WithTokenStore(tokenStore)
newToken, err := a.RefreshOAuth2Token("")
require.NoError(t, err)
assert.Equal(t, "new-access-token", newToken)
tok := tokenStore.GetOAuth2TokenForApp("default", "")
require.NotNil(t, tok)
assert.Equal(t, "new-access-token", tok.OAuth2.AccessToken)
assert.Nil(t, tokenStore.GetOAuth2TokenForApp("default", "alice"))
}
func TestRefreshOAuth2TokenMigratesUnnamedTokenWhenUsernameLookupSucceeds(t *testing.T) {
tokenServer := mockTokenServer(t, "new-access-token", "new-refresh-token")
defer tokenServer.Close()
infoServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"username": "alice",
},
})
}))
defer infoServer.Close()
tokenStore, tempDir := createTempTokenStore(t)
defer os.RemoveAll(tempDir)
expiredTime := uint64(time.Now().Add(-1 * time.Hour).Unix())
err := tokenStore.SaveOAuth2TokenForApp("default", "", "old-access", "old-refresh", expiredTime)
require.NoError(t, err)
cfg := &config.Config{
TokenURL: serverURL(tokenServer, "/token"),
InfoURL: infoServer.URL,
}
a := NewAuth(cfg).WithTokenStore(tokenStore)
newToken, err := a.RefreshOAuth2Token("")
require.NoError(t, err)
assert.Equal(t, "new-access-token", newToken)
assert.Nil(t, tokenStore.GetOAuth2TokenForApp("default", ""))
tok := tokenStore.GetOAuth2TokenForApp("default", "alice")
require.NotNil(t, tok)
assert.Equal(t, "new-access-token", tok.OAuth2.AccessToken)
}
func serverURL(server *httptest.Server, suffix string) string {
return server.URL + suffix
}
package auth
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net"
"net/http"
"net/url"
"os/exec"
"sort"
"strings"
"time"
"github.com/xdevplatform/xurl/config"
xurlErrors "github.com/xdevplatform/xurl/errors"
"github.com/xdevplatform/xurl/store"
"runtime"
"golang.org/x/oauth2"
)
type Auth struct {
TokenStore *store.TokenStore
infoURL string
clientID string
clientSecret string
authURL string
tokenURL string
redirectURI string
redirectURIFromEnv bool
appName string // explicit app override (empty = use default)
}
var openBrowserFunc = openBrowser
var startListenerFunc = StartListener
// NewAuth creates a new Auth object.
// Credentials are resolved in order: env-var config → active app in .xurl store.
// If env var credentials are present, they're also backfilled into any migrated
// app that has tokens but no stored credentials.
func NewAuth(cfg *config.Config) *Auth {
ts := store.NewTokenStoreWithCredentials(cfg.ClientID, cfg.ClientSecret)
// Resolve client ID / secret: env vars take priority, then the active app.
clientID := cfg.ClientID
clientSecret := cfg.ClientSecret
appName := cfg.AppName
app := ts.ResolveApp(appName)
if clientID == "" && app != nil {
clientID = app.ClientID
}
if clientSecret == "" && app != nil {
clientSecret = app.ClientSecret
}
return &Auth{
TokenStore: ts,
infoURL: cfg.InfoURL,
clientID: clientID,
clientSecret: clientSecret,
authURL: cfg.AuthURL,
tokenURL: cfg.TokenURL,
redirectURI: cfg.RedirectURI,
redirectURIFromEnv: cfg.RedirectURIFromEnv,
appName: appName,
}
}
// WithTokenStore sets the token store for the Auth object
func (a *Auth) WithTokenStore(tokenStore *store.TokenStore) *Auth {
a.TokenStore = tokenStore
return a
}
// AppName returns the active app name override (empty means use default).
func (a *Auth) AppName() string {
return a.appName
}
// WithAppName sets the explicit app name override.
func (a *Auth) WithAppName(appName string) *Auth {
a.appName = appName
app := a.TokenStore.ResolveApp(appName)
if app != nil {
if app.ClientID != "" {
a.clientID = app.ClientID
}
if app.ClientSecret != "" {
a.clientSecret = app.ClientSecret
}
}
if !a.redirectURIFromEnv {
a.redirectURI = a.resolveRedirectURIForApp(appName)
}
return a
}
func (a *Auth) resolveRedirectURIForApp(appName string) string {
app := a.TokenStore.ResolveApp(appName)
if app != nil && app.RedirectURI != "" {
return app.RedirectURI
}
return config.DefaultRedirectURI
}
// GetOAuth1Header gets the OAuth1 header for a request
func (a *Auth) GetOAuth1Header(method, urlStr string, additionalParams map[string]string) (string, error) {
token := a.TokenStore.GetOAuth1TokensForApp(a.appName)
if token == nil || token.OAuth1 == nil {
return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("OAuth1 token not found"))
}
oauth1Token := token.OAuth1
parsedURL, err := url.Parse(urlStr)
if err != nil {
return "", xurlErrors.NewAuthError("InvalidURL", err)
}
params := make(map[string]string)
query := parsedURL.Query()
for key := range query {
params[key] = query.Get(key)
}
for key, value := range additionalParams {
params[key] = value
}
params["oauth_consumer_key"] = oauth1Token.ConsumerKey
params["oauth_nonce"] = generateNonce()
params["oauth_signature_method"] = "HMAC-SHA1"
params["oauth_timestamp"] = generateTimestamp()
params["oauth_token"] = oauth1Token.AccessToken
params["oauth_version"] = "1.0"
signature, err := generateSignature(method, urlStr, params, oauth1Token.ConsumerSecret, oauth1Token.TokenSecret)
if err != nil {
return "", xurlErrors.NewAuthError("SignatureGenerationError", err)
}
var oauthParams []string
oauthParams = append(oauthParams, fmt.Sprintf("oauth_consumer_key=\"%s\"", encode(oauth1Token.ConsumerKey)))
oauthParams = append(oauthParams, fmt.Sprintf("oauth_nonce=\"%s\"", encode(params["oauth_nonce"])))
oauthParams = append(oauthParams, fmt.Sprintf("oauth_signature=\"%s\"", encode(signature)))
oauthParams = append(oauthParams, fmt.Sprintf("oauth_signature_method=\"%s\"", encode("HMAC-SHA1")))
oauthParams = append(oauthParams, fmt.Sprintf("oauth_timestamp=\"%s\"", encode(params["oauth_timestamp"])))
oauthParams = append(oauthParams, fmt.Sprintf("oauth_token=\"%s\"", encode(oauth1Token.AccessToken)))
oauthParams = append(oauthParams, fmt.Sprintf("oauth_version=\"%s\"", encode("1.0")))
return "OAuth " + strings.Join(oauthParams, ", "), nil
}
// GetOAuth2Token gets or refreshes an OAuth2 token
func (a *Auth) GetOAuth2Header(username string) (string, error) {
var token *store.Token
if username != "" {
token = a.TokenStore.GetOAuth2TokenForApp(a.appName, username)
} else {
token = a.TokenStore.GetFirstOAuth2TokenForApp(a.appName)
}
if token == nil {
accessToken, err := a.OAuth2Flow(username)
if err != nil {
return "", err
}
return "Bearer " + accessToken, nil
}
accessToken, err := a.RefreshOAuth2Token(username)
if err != nil {
return "", xurlErrors.NewAuthError("RefreshTokenError", err)
}
return "Bearer " + accessToken, nil
}
// OAuth2Flow starts the OAuth2 flow
func (a *Auth) OAuth2Flow(username string) (string, error) {
config := &oauth2.Config{
ClientID: a.clientID,
ClientSecret: a.clientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: a.authURL,
TokenURL: a.tokenURL,
},
RedirectURL: a.redirectURI,
Scopes: getOAuth2Scopes(),
}
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", xurlErrors.NewAuthError("IOError", err)
}
state := base64.StdEncoding.EncodeToString(b)
verifier, challenge := generateCodeVerifierAndChallenge()
authURL := config.AuthCodeURL(state,
oauth2.SetAuthURLParam("code_challenge", challenge),
oauth2.SetAuthURLParam("code_challenge_method", "S256"))
listenerConfig, err := listenerConfigFromRedirectURI(a.redirectURI)
if err != nil {
return "", xurlErrors.NewAuthError("InvalidRedirectURI", err)
}
codeChan := make(chan string, 1)
listenerReady := make(chan struct{})
listenerErrChan := make(chan error, 1)
callback := func(code, receivedState string) error {
if receivedState != state {
return xurlErrors.NewAuthError("InvalidState", errors.New("invalid state parameter"))
}
if code == "" {
return xurlErrors.NewAuthError("InvalidCode", errors.New("empty authorization code"))
}
codeChan <- code
return nil
}
go func() {
if err := startListenerFunc(listenerConfig.Addresses, listenerConfig.CallbackPath, callback, listenerReady); err != nil {
listenerErrChan <- err
}
}()
select {
case <-listenerReady:
case err := <-listenerErrChan:
return "", xurlErrors.NewAuthError("ListenerError", err)
}
err = openBrowserFunc(authURL)
if err != nil {
fmt.Println("Failed to open browser automatically. Please visit this URL manually:")
fmt.Println(authURL)
}
var code string
select {
case code = <-codeChan:
if code == "" {
return "", xurlErrors.NewAuthError("ListenerError", errors.New("oauth2 listener failed"))
}
case err := <-listenerErrChan:
return "", xurlErrors.NewAuthError("ListenerError", err)
case <-time.After(5 * time.Minute):
return "", xurlErrors.NewAuthError("Timeout", errors.New("authentication timed out"))
}
token, err := config.Exchange(context.Background(), code, oauth2.SetAuthURLParam("code_verifier", verifier))
if err != nil {
return "", xurlErrors.NewAuthError("TokenExchangeError", err)
}
usernameStr, resolvedFromLookup := a.resolveStorageUsername(username, token.AccessToken)
if err := a.saveOAuth2Token(usernameStr, token); err != nil {
return "", xurlErrors.NewAuthError("TokenStorageError", err)
}
if username == "" && !resolvedFromLookup {
fmt.Println("Warning: authenticated successfully, but could not resolve your username via /2/users/me.")
fmt.Println("The OAuth2 token was saved without a username label. Re-run `xurl auth oauth2 YOUR_USERNAME` if you want a named token.")
}
return token.AccessToken, nil
}
// RefreshOAuth2Token validates and refreshes an OAuth2 token if needed
func (a *Auth) RefreshOAuth2Token(username string) (string, error) {
storedUsername, token := a.getOAuth2TokenRecord(username)
if token == nil || token.OAuth2 == nil {
return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("oauth2 token not found"))
}
currentTime := time.Now().Unix()
if uint64(currentTime) < token.OAuth2.ExpirationTime {
return token.OAuth2.AccessToken, nil
}
config := &oauth2.Config{
ClientID: a.clientID,
ClientSecret: a.clientSecret,
Endpoint: oauth2.Endpoint{
TokenURL: a.tokenURL,
},
}
tokenSource := config.TokenSource(context.Background(), &oauth2.Token{
RefreshToken: token.OAuth2.RefreshToken,
})
newToken, err := tokenSource.Token()
if err != nil {
return "", xurlErrors.NewAuthError("RefreshTokenError", err)
}
usernameStr := storedUsername
if usernameStr == "" {
resolvedUsername, _ := a.resolveStorageUsername("", newToken.AccessToken)
usernameStr = resolvedUsername
}
if storedUsername == "" && usernameStr != "" {
if err := a.TokenStore.ClearOAuth2TokenForApp(a.appName, storedUsername); err != nil {
return "", xurlErrors.NewAuthError("RefreshTokenError", err)
}
}
if err := a.saveOAuth2Token(usernameStr, newToken); err != nil {
return "", xurlErrors.NewAuthError("RefreshTokenError", err)
}
return newToken.AccessToken, nil
}
type oauth2ListenerConfig struct {
Addresses []string
CallbackPath string
}
func listenerConfigFromRedirectURI(redirectURI string) (oauth2ListenerConfig, error) {
parsedURL, err := url.Parse(redirectURI)
if err != nil {
return oauth2ListenerConfig{}, err
}
host := parsedURL.Hostname()
if host == "" {
host = "localhost"
}
port := parsedURL.Port()
if port == "" {
port = "8080"
}
callbackPath := parsedURL.Path
if callbackPath == "" {
callbackPath = "/callback"
}
return oauth2ListenerConfig{
Addresses: listenerAddressesForHost(host, port),
CallbackPath: callbackPath,
}, nil
}
func listenerAddressesForHost(host, port string) []string {
if strings.EqualFold(host, "localhost") {
return []string{
net.JoinHostPort("127.0.0.1", port),
net.JoinHostPort("::1", port),
}
}
return []string{net.JoinHostPort(host, port)}
}
func (a *Auth) resolveStorageUsername(explicitUsername, accessToken string) (string, bool) {
if explicitUsername != "" {
return explicitUsername, true
}
username, err := a.fetchUsername(accessToken)
if err != nil {
return "", false
}
return username, true
}
func (a *Auth) getOAuth2TokenRecord(username string) (string, *store.Token) {
if username != "" {
return username, a.TokenStore.GetOAuth2TokenForApp(a.appName, username)
}
return a.TokenStore.GetFirstOAuth2TokenRecordForApp(a.appName)
}
func (a *Auth) saveOAuth2Token(username string, token *oauth2.Token) error {
expirationTime := uint64(time.Now().Add(time.Duration(token.Expiry.Unix()-time.Now().Unix()) * time.Second).Unix())
return a.TokenStore.SaveOAuth2TokenForApp(a.appName, username, token.AccessToken, token.RefreshToken, expirationTime)
}
// GetBearerTokenHeader gets the bearer token from the token store
func (a *Auth) GetBearerTokenHeader() (string, error) {
token := a.TokenStore.GetBearerTokenForApp(a.appName)
if token == nil {
return "", xurlErrors.NewAuthError("TokenNotFound", errors.New("bearer token not found"))
}
return "Bearer " + token.Bearer, nil
}
func (a *Auth) fetchUsername(accessToken string) (string, error) {
req, err := http.NewRequest("GET", a.infoURL, nil)
if err != nil {
return "", xurlErrors.NewAuthError("RequestCreationError", err)
}
req.Header.Add("Authorization", "Bearer "+accessToken)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", xurlErrors.NewAuthError("NetworkError", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", xurlErrors.NewAuthError("IOError", err)
}
var data map[string]any
if err := json.Unmarshal(body, &data); err != nil {
return "", xurlErrors.NewAuthError("JSONDeserializationError", err)
}
if data["data"] != nil {
if userData, ok := data["data"].(map[string]any); ok {
if username, ok := userData["username"].(string); ok {
return username, nil
}
}
}
return "", xurlErrors.NewAuthError("UsernameNotFound", errors.New("username not found when fetching username"))
}
func generateSignature(method, urlStr string, params map[string]string, consumerSecret, tokenSecret string) (string, error) {
parsedURL, err := url.Parse(urlStr)
if err != nil {
return "", xurlErrors.NewAuthError("InvalidURL", err)
}
baseURL := fmt.Sprintf("%s://%s%s", parsedURL.Scheme, parsedURL.Host, parsedURL.Path)
var keys []string
for key := range params {
keys = append(keys, key)
}
sort.Strings(keys)
var paramPairs []string
for _, key := range keys {
paramPairs = append(paramPairs, fmt.Sprintf("%s=%s", encode(key), encode(params[key])))
}
paramString := strings.Join(paramPairs, "&")
signatureBaseString := fmt.Sprintf("%s&%s&%s",
strings.ToUpper(method),
encode(baseURL),
encode(paramString))
signingKey := fmt.Sprintf("%s&%s", encode(consumerSecret), encode(tokenSecret))
h := hmac.New(sha1.New, []byte(signingKey))
h.Write([]byte(signatureBaseString))
signature := base64.StdEncoding.EncodeToString(h.Sum(nil))
return signature, nil
}
func generateNonce() string {
n, _ := rand.Int(rand.Reader, big.NewInt(1000000000))
return n.String()
}
func generateTimestamp() string {
return fmt.Sprintf("%d", time.Now().Unix())
}
func encode(s string) string {
return url.QueryEscape(s)
}
func generateCodeVerifierAndChallenge() (string, string) {
b := make([]byte, 32)
rand.Read(b)
verifier := base64.RawURLEncoding.EncodeToString(b)
h := sha256.New()
h.Write([]byte(verifier))
challenge := base64.RawURLEncoding.EncodeToString(h.Sum(nil))
return verifier, challenge
}
func getOAuth2Scopes() []string {
readScopes := []string{
"tweet.read",
"users.read",
"bookmark.read",
"follows.read",
"list.read",
"block.read",
"mute.read",
"like.read",
"users.email",
"dm.read",
}
writeScopes := []string{
"tweet.write",
"tweet.moderate.write",
"follows.write",
"bookmark.write",
"block.write",
"mute.write",
"like.write",
"list.write",
"media.write",
"dm.write",
}
otherScopes := []string{
"offline.access",
"space.read",
}
var scopes []string
scopes = append(scopes, readScopes...)
scopes = append(scopes, writeScopes...)
scopes = append(scopes, otherScopes...)
return scopes
}
func openBrowser(url string) error {
cmd, args := browserLaunchCommand(runtime.GOOS, url)
return exec.Command(cmd, args...).Start()
}
func browserLaunchCommand(goos, url string) (string, []string) {
switch goos {
case "windows":
return "rundll32", []string{"url.dll,FileProtocolHandler", url}
case "darwin":
return "open", []string{url}
default:
return "xdg-open", []string{url}
}
}
package auth
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
xurlErrors "github.com/xdevplatform/xurl/errors"
)
func StartListener(addresses []string, callbackPath string, callback func(code, state string) error, ready chan<- struct{}) error {
mux := http.NewServeMux()
done := make(chan error, 1)
servers := make([]*http.Server, 0, len(addresses))
listeners := make([]net.Listener, 0, len(addresses))
var doneOnce sync.Once
finish := func(err error) {
doneOnce.Do(func() {
done <- err
go func() {
for _, server := range servers {
_ = server.Shutdown(context.Background())
}
}()
})
}
mux.HandleFunc(callbackPath, func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
err := callback(code, state)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "Error: %s", err.Error())
finish(err)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Authentication successful! You can close this window.")
finish(nil)
})
for _, address := range addresses {
listener, err := net.Listen("tcp", address)
if err != nil {
for _, existing := range listeners {
_ = existing.Close()
}
return xurlErrors.NewAuthError("ServerError", err)
}
listeners = append(listeners, listener)
servers = append(servers, &http.Server{
Addr: address,
Handler: mux,
})
}
if ready != nil {
close(ready)
}
for i, listener := range listeners {
server := servers[i]
go func(server *http.Server, listener net.Listener) {
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
finish(xurlErrors.NewAuthError("ServerError", err))
}
}(server, listener)
}
select {
case err := <-done:
return err
case <-time.After(5 * time.Minute):
for _, server := range servers {
_ = server.Shutdown(context.Background())
}
return xurlErrors.NewAuthError("Timeout", errors.New("timeout waiting for callback"))
}
}
package cli
import (
"fmt"
"github.com/spf13/cobra"
"github.com/xdevplatform/xurl/version"
)
// CreateVersionCommand creates the version command
func CreateVersionCommand() *cobra.Command {
var versionCmd = &cobra.Command{
Use: "version",
Short: "Show xurl version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("xurl %s\n", version.Version)
},
}
return versionCmd
}
package main
import (
"fmt"
"os"
"github.com/xdevplatform/xurl/auth"
"github.com/xdevplatform/xurl/cli"
"github.com/xdevplatform/xurl/config"
)
func main() {
// Create a new config from environment variables
config := config.NewConfig()
auth := auth.NewAuth(config)
// Create the root command
rootCmd := cli.CreateRootCommand(config, auth)
// Execute the command
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
.PHONY: build
build:
go build -o xurl
.PHONY: install
install:
go install
.PHONY: clean
clean:
rm -f xurl
.PHONY: test
test:
go test -v ./...
.PHONY: format
format:
go fmt ./...
.PHONY: all
all: build test format
.PHONY: release
release:
goreleaser release --clean