diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8dcc39a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + name: Build & Test + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 7.0.x + 8.0.x + 9.0.x + 10.0.x + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --no-restore --configuration Release + + - name: Test + run: dotnet test --no-build --configuration Release + env: + API_KEY: ${{ secrets.API_KEY }} + + - name: Run examples + env: + API_KEY: ${{ secrets.API_KEY }} + run: | + for proj in examples/*/; do + name=$(basename "$proj") + echo "::group::$name" + dotnet run --project "$proj" --configuration Release -- "$API_KEY" + echo "::endgroup::" + done + + pack: + runs-on: ubuntu-latest + needs: build + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Pack + run: dotnet pack serpapi/serpapi.csproj --configuration Release + + - name: Upload package + uses: actions/upload-artifact@v7 + with: + name: nuget-package + path: serpapi/bin/Release/*.nupkg diff --git a/.github/workflows/dotnet-core.yml b/.github/workflows/dotnet-core.yml deleted file mode 100644 index cab59cf..0000000 --- a/.github/workflows/dotnet-core.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: serpapi-dotnet - -on: [push] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - dotnet: ['7.0.x', '8.0.x'] - #dotnet: ['6.0.x', '7.0.x', '8.0.x'] - name: test with Dotnet ${{ matrix.dotnet }} test - steps: - - uses: actions/checkout@v2 - - name: Setup dotnet - uses: actions/setup-dotnet@v1 - with: - dotnet-version: ${{ matrix.dotnet }} - - name: test - run: dotnet test - env: - API_KEY: ${{secrets.API_KEY}} - DOTNET_VERSION: ${{matrix.dotnet}} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d1b149d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: Release + +on: + push: + tags: ['v*'] + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --configuration Release --no-build + env: + API_KEY: ${{ secrets.API_KEY }} + + - name: Pack + run: dotnet pack serpapi/serpapi.csproj --configuration Release --no-build + + - name: Publish to NuGet + run: dotnet nuget push serpapi/bin/Release/*.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json --skip-duplicate + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: gh release create "${{ github.ref_name }}" --generate-notes diff --git a/.gitignore b/.gitignore index 529cbe2..cec29e1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,18 @@ -serpapi/obj/ -test/obj/ -test/bin/ -serpapi/bin +## .NET +bin/ +obj/ +*.nupkg +*.snupkg + +## IDE +.vs/ .vscode/ +*.user +*.suo + +## OS +.DS_Store +Thumbs.db + +## Build artifacts newtonsoft.json/ -derive.rb diff --git a/Directory.Build.props b/Directory.Build.props index a2816f1..2366cbd 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,10 +1,8 @@ - - $(TargetFramework);net7.0 - - disable - preview - true - true - + + true + true + false + LatestMajor + diff --git a/Directory.Packages.props b/Directory.Packages.props index acfa340..7a7ad8c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,9 +3,15 @@ true - - - - + + + + + + + + + + - + \ No newline at end of file diff --git a/Makefile b/Makefile deleted file mode 100644 index bad1be2..0000000 --- a/Makefile +++ /dev/null @@ -1,56 +0,0 @@ - -# Automate SerpApi dotnet library -# compilation, test and release -# -.PHONY: test - -name=serpapi -root=`pwd` -example=google - -all: clean restore build test - -# clean-up previous build -clean: - rm -rf test/obj test/bin - rm -rf serpapi/obj serpapi/bin - dotnet clean - -# rebuild local state -restore: - dotnet restore - -# build for all target framework defined in serpapi/serpapi.csproj -build: - dotnet build --configuration Release - -# run test regression -test: - dotnet test --configuration Release - -# run a simple application -run: - dotnet run - -# package the library -pack: - dotnet pack - -oobt: pack - $(MAKE) run_oobt example=google - $(MAKE) run_oobt example=bing - -run_oobt: - cd example/${example} ; \ - dotnet add package --package-directory ${root}/${name} ${name} ; \ - dotnet build ; \ - dotnet run - -# Dotnet -# -# https://docs.microsoft.com/en-us/nuget/quickstart/create-and-publish-a-package-using-the-dotnet-cli -# -# Package API -release: oobt - open serpapi/bin/Debug - open -a "Google\ Chrome" https://www.nuget.org/packages/manage/upload diff --git a/README.md b/README.md index c0e939a..b46a647 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,483 @@ +# SerpApi .NET Library -dotnet is not available on arm64 debian 12 +[![NuGet](https://img.shields.io/nuget/v/serpapi)](https://www.nuget.org/packages/serpapi) +[![Build](https://github.com/serpapi/serpapi-dotnet/actions/workflows/ci.yml/badge.svg)](https://github.com/serpapi/serpapi-dotnet/actions/workflows/ci.yml) + +Integrate search data into your .NET application, AI workflow, or LLM/RAG pipeline. This is the official .NET client for [SerpApi](https://serpapi.com). + +SerpApi supports Google, Google Maps, Google Shopping, Bing, Baidu, Yandex, Yahoo, DuckDuckGo, eBay, Walmart, YouTube, and [100+ engines](https://serpapi.com). + +## Features + +- Async-first with full `CancellationToken` support +- Sync convenience wrappers +- `IAsyncEnumerable` pagination +- Dependency injection integration (`IHttpClientFactory`) +- Targets .NET Standard 2.0, .NET 7, 8, 9, and 10 +- Zero external runtime dependencies + +## Installation + +```bash +dotnet add package serpapi +``` + +## Simple Usage + +```csharp +using SerpApi; + +using var client = new SerpApiClient(Environment.GetEnvironmentVariable("SERPAPI_KEY")!); + +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_light", + ["q"] = "coffee" +}); + +foreach (var result in results.OrganicResults!.Value.EnumerateArray()) +{ + Console.WriteLine(result.GetProperty("title").GetString()); +} +``` + +### Error handling + +```csharp +try +{ + using var results = await client.SearchAsync(parameters); +} +catch (SerpApiKeyException) { /* 401 — invalid API key */ } +catch (SerpApiHttpException ex) { /* 429, 500, etc — ex.StatusCode */ } +catch (SerpApiTimeoutException) { /* request timed out */ } +catch (SerpApiException ex) { /* catch-all */ } +``` + +## Search API usage + +### Get JSON results + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_light", + ["q"] = "coffee", + ["num"] = "10" +}); + +Console.WriteLine(results.SearchId); +Console.WriteLine(results.OrganicResults); +Console.WriteLine(results["local_results"]); +``` + +### Get HTML results + +```csharp +string html = await client.HtmlAsync(new Dictionary +{ + ["engine"] = "google_light", + ["q"] = "coffee" +}); +``` + +### Pagination + +```csharp +// Next page +using var page2 = await client.NextPageAsync(results); + +// Iterate all pages as an async stream +await foreach (var page in client.SearchPagesAsync(parameters, maxPages: 5)) +{ + using (page) + { + Console.WriteLine($"Page has {page.OrganicResults?.GetArrayLength()} results"); + } +} +``` + +### Search concurrently + +```csharp +var tasks = new[] +{ + client.SearchAsync(new Dictionary + { + ["engine"] = "google_light", ["q"] = "coffee" + }), + client.SearchAsync(new Dictionary + { + ["engine"] = "google_news_light", ["q"] = "coffee" + }) +}; + +try +{ + var results = await Task.WhenAll(tasks); + // Process results here. +} +finally +{ + foreach (var task in tasks) + { + if (task.Status == TaskStatus.RanToCompletion) + task.Result.Dispose(); + } +} +``` + +### Location API + +```csharp +var locations = await client.LocationAsync("Austin, TX", limit: 3); +foreach (var loc in locations.EnumerateArray()) +{ + Console.WriteLine(loc.GetProperty("name").GetString()); +} +``` + +### Search Archive API + +Retrieve a previous search (0 credits): + +```csharp +using var archived = await client.SearchArchiveAsync("previous_search_id"); +``` + +### Account API + +```csharp +using var account = await client.AccountAsync(); +Console.WriteLine(account["plan_id"]); +``` + +## Basic examples per search engine + +### Search Google + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google", + ["q"] = "coffee", + ["location"] = "Austin, Texas" +}); +``` + +* see: https://serpapi.com/search-api + +### Search Google Light + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_light", + ["q"] = "coffee" +}); +``` + +* see: https://serpapi.com/google-light-api + +### Search Google Scholar + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_scholar", + ["q"] = "machine learning" +}); +``` + +* see: https://serpapi.com/google-scholar-api + +### Search Google News + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_news", + ["q"] = "artificial intelligence" +}); +``` + +* see: https://serpapi.com/google-news-api + +### Search Google Maps + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_maps", + ["q"] = "pizza", + ["ll"] = "@40.7455096,-74.0083012,14z" +}); +``` + +* see: https://serpapi.com/google-maps-api + +### Search Google Shopping + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_shopping", + ["q"] = "laptop" +}); +``` + +* see: https://serpapi.com/google-shopping-api + +### Search Google Jobs + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_jobs", + ["q"] = "software engineer" +}); +``` + +* see: https://serpapi.com/google-jobs-api + +### Search Google Images + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_images", + ["q"] = "sunset" +}); +``` + +* see: https://serpapi.com/images-results + +### Search Google Finance + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_finance", + ["q"] = "AAPL:NASDAQ" +}); +``` + +* see: https://serpapi.com/google-finance-api + +### Search Bing + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "bing", + ["q"] = "coffee" +}); +``` + +* see: https://serpapi.com/bing-search-api + +### Search DuckDuckGo + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "duckduckgo", + ["q"] = "coffee" +}); +``` + +* see: https://serpapi.com/duckduckgo-search-api + +### Search Baidu + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "baidu", + ["q"] = "coffee" +}); +``` + +* see: https://serpapi.com/baidu-search-api + +### Search Yahoo + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "yahoo", + ["p"] = "coffee" +}); +``` + +* see: https://serpapi.com/yahoo-search-api + +### Search YouTube + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "youtube", + ["search_query"] = "latte art" +}); +``` + +* see: https://serpapi.com/youtube-search-api + +### Search Walmart + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "walmart", + ["query"] = "coffee maker" +}); +``` + +* see: https://serpapi.com/walmart-search-api + +### Search eBay + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "ebay", + ["_nkw"] = "laptop" +}); +``` + +* see: https://serpapi.com/ebay-search-api + +### Search Amazon + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "amazon", + ["k"] = "coffee" +}); +``` + +* see: https://serpapi.com/amazon-search-api + +### Search Naver + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "naver", + ["query"] = "coffee" +}); +``` + +* see: https://serpapi.com/naver-search-api + +### Search Apple App Store + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "apple_app_store", + ["term"] = "coffee" +}); +``` + +* see: https://serpapi.com/apple-app-store + +### Search Home Depot + +```csharp +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "home_depot", + ["q"] = "drill" +}); +``` + +* see: https://serpapi.com/home-depot-search-api + +## Configuration + +```csharp +using var client = new SerpApiClient("YOUR_API_KEY", new SerpApiClientOptions +{ + Timeout = TimeSpan.FromSeconds(30) +}); +``` + +### Dependency Injection + +```csharp +builder.Services.AddSerpApi(options => +{ + options.ApiKey = builder.Configuration["SerpApi:ApiKey"]!; + options.Timeout = TimeSpan.FromSeconds(30); +}); +``` + +Uses `IHttpClientFactory` for connection management. + +#### Resilience + +Install the `Microsoft.Extensions.Http.Resilience` package: + +```bash +dotnet add package Microsoft.Extensions.Http.Resilience +``` + +```csharp +builder.Services.AddSerpApi(options => +{ + options.ApiKey = builder.Configuration["SerpApi:ApiKey"]!; +}) +.AddStandardResilienceHandler(); +``` + +#### Corporate proxy + +```csharp +var handler = new HttpClientHandler +{ + Proxy = new WebProxy("http://proxy.corp.example:8080"), + UseProxy = true +}; +using var client = new SerpApiClient( + new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "YOUR_API_KEY" }); +``` + +## Examples + +See [`examples/`](examples/) for runnable projects: + +| Example | Use Case | Description | +|---------|----------|-------------| +| [LeadFinder](examples/LeadFinder/) | Lead generation | Find local businesses via Google Maps for sales outreach | +| [CompetitorTracker](examples/CompetitorTracker/) | SEO & competitive intel | Monitor brand vs competitor SERP positions across engines | +| [RankTracker](examples/RankTracker/) | SEO rank monitoring | Track keyword positions page-by-page with pagination | +| [PriceMonitor](examples/PriceMonitor/) | Price monitoring | Compare product prices across Google Shopping and Walmart | +| [AiResearchAgent](examples/AiResearchAgent/) | AI/RAG pipelines | Gather multi-source context (web + news + scholar) for LLMs | +| [ContentDiscovery](examples/ContentDiscovery/) | Market research | Find trending topics, PAA questions, and content gaps | +| [ErrorHandling](examples/ErrorHandling/) | Reliability | Exception types, retry patterns, and graceful degradation | +| [DependencyInjection](examples/DependencyInjection/) | Enterprise integration | ASP.NET Core / generic host with `IHttpClientFactory` | + +```bash +export SERPAPI_KEY=your_key_here +cd examples/LeadFinder +dotnet run +``` + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/serpapi/serpapi-dotnet. + +```bash +git clone https://github.com/serpapi/serpapi-dotnet.git +cd serpapi-dotnet +dotnet build +dotnet test +``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/examples/AiResearchAgent/AiResearchAgent.csproj b/examples/AiResearchAgent/AiResearchAgent.csproj new file mode 100644 index 0000000..3e18b74 --- /dev/null +++ b/examples/AiResearchAgent/AiResearchAgent.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/examples/AiResearchAgent/Program.cs b/examples/AiResearchAgent/Program.cs new file mode 100644 index 0000000..397e0c6 --- /dev/null +++ b/examples/AiResearchAgent/Program.cs @@ -0,0 +1,127 @@ +// AI Research Agent: gather multi-source context for LLM/RAG pipelines. +// Use case: Feed real-time search data into an AI assistant or RAG system. +// Fans out across web, news, and scholar — returns structured context. + +using SerpApi; +using System.Text.Json; + +var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY"); +if (string.IsNullOrEmpty(apiKey)) +{ + Console.WriteLine("Usage: dotnet run -- "); + Console.WriteLine(" or: SERPAPI_KEY=... dotnet run"); + return; +} + +using var client = new SerpApiClient(apiKey); + +var topic = args.Length > 1 ? args[1] : "retrieval augmented generation"; +Console.WriteLine($"Research topic: \"{topic}\"\n"); + +// Fan-out: web + news + academic papers in parallel (RAG context gathering) +var web = client.SearchAsync(new Dictionary +{ + ["engine"] = "google_light", + ["q"] = topic, + ["num"] = "5" +}); + +var news = client.SearchAsync(new Dictionary +{ + ["engine"] = "google_news_light", + ["q"] = topic +}); + +var scholar = client.SearchAsync(new Dictionary +{ + ["engine"] = "google_scholar", + ["q"] = topic, + ["as_ylo"] = DateTime.UtcNow.Year.ToString() +}); + +var sources = new List<(string Type, string Title, string Url, string Snippet)>(); + +try +{ + await Task.WhenAll(web, news, scholar); + + // Collect web results + var webResult = await web; + if (webResult.OrganicResults is { } webItems) + { + foreach (var r in webItems.EnumerateArray().Take(5)) + { + sources.Add(( + "web", + r.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "", + r.TryGetProperty("link", out var l) ? l.GetString() ?? "" : "", + r.TryGetProperty("snippet", out var s) ? s.GetString() ?? "" : "" + )); + } + } + + // Collect news + var newsResult = await news; + var newsItems = newsResult["news_results"]; + if (newsItems is { } ni) + { + foreach (var r in ni.EnumerateArray().Take(3)) + { + sources.Add(( + "news", + r.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "", + r.TryGetProperty("link", out var l) ? l.GetString() ?? "" : "", + r.TryGetProperty("snippet", out var s) ? s.GetString() ?? "" : "" + )); + } + } + + // Collect academic papers + var scholarResult = await scholar; + if (scholarResult.OrganicResults is { } papers) + { + foreach (var r in papers.EnumerateArray().Take(3)) + { + sources.Add(( + "scholar", + r.TryGetProperty("title", out var t) ? t.GetString() ?? "" : "", + r.TryGetProperty("link", out var l) ? l.GetString() ?? "" : "", + r.TryGetProperty("snippet", out var s) ? s.GetString() ?? "" : "" + )); + } + } + + // Output structured context (ready for LLM prompt injection) + Console.WriteLine($"Gathered {sources.Count} sources for RAG context:\n"); + + foreach (var group in sources.GroupBy(s => s.Type)) + { + Console.WriteLine($"=== {group.Key.ToUpper()} ==="); + foreach (var (type, title, url, snippet) in group) + { + Console.WriteLine($" • {title}"); + if (!string.IsNullOrEmpty(snippet)) + Console.WriteLine($" {snippet[..Math.Min(100, snippet.Length)]}..."); + Console.WriteLine($" {url}\n"); + } + } + + // In a real RAG pipeline, you'd format these as context and pass to an LLM: + // var prompt = $"Based on these sources:\n{FormatSources(sources)}\n\nAnswer: {topic}"; +} +catch (SerpApiException ex) +{ + Console.WriteLine($"Search error: {ex.Message}"); +} +finally +{ + DisposeCompleted(web); + DisposeCompleted(news); + DisposeCompleted(scholar); +} + +static void DisposeCompleted(Task task) +{ + if (task.Status == TaskStatus.RanToCompletion) + task.Result.Dispose(); +} diff --git a/examples/CompetitorTracker/CompetitorTracker.csproj b/examples/CompetitorTracker/CompetitorTracker.csproj new file mode 100644 index 0000000..3e18b74 --- /dev/null +++ b/examples/CompetitorTracker/CompetitorTracker.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/examples/CompetitorTracker/Program.cs b/examples/CompetitorTracker/Program.cs new file mode 100644 index 0000000..199130c --- /dev/null +++ b/examples/CompetitorTracker/Program.cs @@ -0,0 +1,90 @@ +// Competitor Tracker: monitor your brand vs competitors across search engines. +// Use case: Marketing teams tracking SERP visibility for key terms. +// Runs searches across Google and Bing, reports who ranks where. + +using SerpApi; +using System.Text.Json; + +var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY"); +if (string.IsNullOrEmpty(apiKey)) +{ + Console.WriteLine("Usage: dotnet run -- "); + Console.WriteLine(" or: SERPAPI_KEY=... dotnet run"); + return; +} + +using var client = new SerpApiClient(apiKey); + +var keyword = "project management software"; +var trackedDomains = new[] { "asana.com", "monday.com", "clickup.com", "notion.so" }; + +Console.WriteLine($"Tracking: \"{keyword}\""); +Console.WriteLine($"Domains: {string.Join(", ", trackedDomains)}\n"); + +// Search Google and Bing in parallel +var google = client.SearchAsync(new Dictionary +{ + ["engine"] = "google_light", + ["q"] = keyword, + ["num"] = "20" +}); + +var bing = client.SearchAsync(new Dictionary +{ + ["engine"] = "bing", + ["q"] = keyword, + ["count"] = "20" +}); + +try +{ + await Task.WhenAll(google, bing); + + var engines = new[] { ("Google", await google), ("Bing", await bing) }; + foreach (var (engineName, response) in engines) + { + Console.WriteLine($"=== {engineName} Rankings ==="); + + var organic = response.OrganicResults; + if (organic is not { } results) + { + Console.WriteLine(" No results\n"); + continue; + } + + foreach (var domain in trackedDomains) + { + var position = -1; + var idx = 0; + foreach (var r in results.EnumerateArray()) + { + idx++; + var link = r.TryGetProperty("link", out var l) ? l.GetString() ?? "" : ""; + if (link.Contains(domain, StringComparison.OrdinalIgnoreCase)) + { + position = idx; + break; + } + } + + var status = position > 0 ? $"#{position}" : "Not in top 20"; + Console.WriteLine($" {domain,-20} {status}"); + } + Console.WriteLine(); + } +} +catch (SerpApiException ex) +{ + Console.WriteLine($"Search error: {ex.Message}"); +} +finally +{ + DisposeCompleted(google); + DisposeCompleted(bing); +} + +static void DisposeCompleted(Task task) +{ + if (task.Status == TaskStatus.RanToCompletion) + task.Result.Dispose(); +} diff --git a/examples/ContentDiscovery/ContentDiscovery.csproj b/examples/ContentDiscovery/ContentDiscovery.csproj new file mode 100644 index 0000000..3e18b74 --- /dev/null +++ b/examples/ContentDiscovery/ContentDiscovery.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/examples/ContentDiscovery/Program.cs b/examples/ContentDiscovery/Program.cs new file mode 100644 index 0000000..f3914d6 --- /dev/null +++ b/examples/ContentDiscovery/Program.cs @@ -0,0 +1,88 @@ +// Content Discovery: find trending topics and content gaps for a niche. +// Use case: Content marketers finding what to write about next. +// Progressive refinement: trending news → related questions → competitor content. + +using SerpApi; +using System.Text.Json; + +var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY"); +if (string.IsNullOrEmpty(apiKey)) +{ + Console.WriteLine("Usage: dotnet run -- "); + Console.WriteLine(" or: SERPAPI_KEY=... dotnet run"); + return; +} + +using var client = new SerpApiClient(apiKey); + +var niche = "sustainable fashion"; +Console.WriteLine($"Content discovery for: \"{niche}\"\n"); + +// Step 1: What's trending in news? +Console.WriteLine("=== Trending News ==="); +using var newsResults = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_news_light", + ["q"] = niche +}); + +var newsItems = newsResults["news_results"]; +if (newsItems is { } news) +{ + foreach (var item in news.EnumerateArray().Take(5)) + { + var title = item.TryGetProperty("title", out var t) ? t.GetString() : ""; + var date = item.TryGetProperty("date", out var d) ? d.GetString() : ""; + Console.WriteLine($" • {title} ({date})"); + } +} + +// Step 2: What questions do people ask? +Console.WriteLine("\n=== People Also Ask ==="); +using var searchResults = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_light", + ["q"] = niche +}); + +var paa = searchResults["related_questions"]; +if (paa is { } questions) +{ + foreach (var q in questions.EnumerateArray().Take(5)) + { + var question = q.TryGetProperty("question", out var qText) ? qText.GetString() : ""; + Console.WriteLine($" • {question}"); + } +} + +// Step 3: What are competitors ranking for? +Console.WriteLine("\n=== Top Organic Competitors ==="); +if (searchResults.OrganicResults is { } organic) +{ + var domains = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var r in organic.EnumerateArray()) + { + var link = r.TryGetProperty("link", out var l) ? l.GetString() ?? "" : ""; + if (Uri.TryCreate(link, UriKind.Absolute, out var uri)) + { + if (domains.Add(uri.Host)) + { + var title = r.TryGetProperty("title", out var t) ? t.GetString() : ""; + Console.WriteLine($" • {uri.Host,-30} \"{title}\""); + } + } + if (domains.Count >= 5) break; + } +} + +// Step 4: Related searches (content gap opportunities) +Console.WriteLine("\n=== Content Gap Ideas (Related Searches) ==="); +var related = searchResults["related_searches"]; +if (related is { } relatedItems) +{ + foreach (var r in relatedItems.EnumerateArray().Take(8)) + { + var query = r.TryGetProperty("query", out var q) ? q.GetString() : ""; + Console.WriteLine($" • {query}"); + } +} diff --git a/examples/DependencyInjection/DependencyInjection.csproj b/examples/DependencyInjection/DependencyInjection.csproj new file mode 100644 index 0000000..bcf4e3c --- /dev/null +++ b/examples/DependencyInjection/DependencyInjection.csproj @@ -0,0 +1,14 @@ + + + Exe + net8.0 + enable + enable + false + + + + + + + diff --git a/examples/DependencyInjection/Program.cs b/examples/DependencyInjection/Program.cs new file mode 100644 index 0000000..8cf30c5 --- /dev/null +++ b/examples/DependencyInjection/Program.cs @@ -0,0 +1,35 @@ +using Microsoft.Extensions.DependencyInjection; +using SerpApi; + +var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY"); +if (string.IsNullOrEmpty(apiKey)) +{ + Console.WriteLine("Usage: dotnet run -- "); + return; +} + +// Configure services (like ASP.NET Core's Program.cs) +var services = new ServiceCollection(); + +services.AddSerpApi(options => +{ + options.ApiKey = apiKey; + options.Timeout = TimeSpan.FromSeconds(30); +}); + +// Simulates what ASP.NET Core does at startup +var provider = services.BuildServiceProvider(); + +// Resolve from DI (like constructor injection in a controller) +var client = provider.GetRequiredService(); + +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google", + ["q"] = "dependency injection patterns" +}); + +Console.WriteLine($"Search ID: {results.SearchId}"); +Console.WriteLine($"Results: {results.OrganicResults?.GetArrayLength()}"); + +// The client lifecycle is managed by DI — no manual dispose needed diff --git a/examples/Directory.Build.props b/examples/Directory.Build.props new file mode 100644 index 0000000..6e5343f --- /dev/null +++ b/examples/Directory.Build.props @@ -0,0 +1,6 @@ + + + + false + + diff --git a/examples/ErrorHandling/ErrorHandling.csproj b/examples/ErrorHandling/ErrorHandling.csproj new file mode 100644 index 0000000..3e18b74 --- /dev/null +++ b/examples/ErrorHandling/ErrorHandling.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/examples/ErrorHandling/Program.cs b/examples/ErrorHandling/Program.cs new file mode 100644 index 0000000..2873e6d --- /dev/null +++ b/examples/ErrorHandling/Program.cs @@ -0,0 +1,78 @@ +using SerpApi; + +// Demonstrate all exception types + +// 1. Missing API key +Console.WriteLine("=== SerpApiKeyException (empty key) ==="); +try +{ + var bad = new SerpApiClient(""); +} +catch (SerpApiKeyException ex) +{ + Console.WriteLine($" Caught: {ex.Message}"); +} + +// 2. Invalid API key (requires network) +var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY"); +if (string.IsNullOrEmpty(apiKey)) +{ + Console.WriteLine("\nSkipping network tests (no API key)."); + Console.WriteLine("Usage: dotnet run -- "); + return; +} + +Console.WriteLine("\n=== SerpApiKeyException (invalid key) ==="); +using (var client = new SerpApiClient("invalid_key_12345")) +{ + try + { + using var results = await client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + }); + } + catch (SerpApiKeyException ex) + { + Console.WriteLine($" Caught: {ex.Message}"); + } +} + +// 3. Timeout +Console.WriteLine("\n=== SerpApiTimeoutException ==="); +using (var client = new SerpApiClient(apiKey, new SerpApiClientOptions +{ + Timeout = TimeSpan.FromMilliseconds(1) // impossibly short +})) +{ + try + { + using var results = await client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "timeout test" + }); + } + catch (SerpApiTimeoutException ex) + { + Console.WriteLine($" Caught: {ex.Message}"); + } +} + +// 4. Successful request with error handling +Console.WriteLine("\n=== Successful search with error handling ==="); +using var safeClient = new SerpApiClient(apiKey); +try +{ + using var results = await safeClient.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "error handling best practices" + }); + Console.WriteLine($" Success! Got {results.OrganicResults?.GetArrayLength()} results"); +} +catch (SerpApiException ex) +{ + Console.WriteLine($" Error: {ex.Message}"); +} diff --git a/examples/LeadFinder/LeadFinder.csproj b/examples/LeadFinder/LeadFinder.csproj new file mode 100644 index 0000000..3e18b74 --- /dev/null +++ b/examples/LeadFinder/LeadFinder.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/examples/LeadFinder/Program.cs b/examples/LeadFinder/Program.cs new file mode 100644 index 0000000..4af470f --- /dev/null +++ b/examples/LeadFinder/Program.cs @@ -0,0 +1,51 @@ +// Lead Finder: discover local businesses for outreach using Google Maps. +// Use case: Sales teams finding leads by location and category. +// Runs 1 API call, extracts business names, ratings, and addresses. + +using SerpApi; +using System.Text.Json; + +var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY"); +if (string.IsNullOrEmpty(apiKey)) +{ + Console.WriteLine("Usage: dotnet run -- "); + Console.WriteLine(" or: SERPAPI_KEY=... dotnet run"); + return; +} + +using var client = new SerpApiClient(apiKey); + +// Find plumbers in Austin, TX via Google Maps +Console.WriteLine("Finding leads: plumbers in Austin, TX\n"); + +using var results = await client.SearchAsync(new Dictionary +{ + ["engine"] = "google_maps", + ["q"] = "plumbers", + ["ll"] = "@30.2672,-97.7431,14z", + ["type"] = "search" +}); + +var localResults = results["local_results"]; +if (localResults is not { } leads) +{ + Console.WriteLine("No results found."); + return; +} + +Console.WriteLine($"Found {leads.GetArrayLength()} leads:\n"); + +foreach (var lead in leads.EnumerateArray().Take(10)) +{ + var name = lead.TryGetProperty("title", out var t) ? t.GetString() : "Unknown"; + var rating = lead.TryGetProperty("rating", out var r) ? r.ToString() : "N/A"; + var reviews = lead.TryGetProperty("reviews", out var rv) ? rv.ToString() : "0"; + var address = lead.TryGetProperty("address", out var a) ? a.GetString() : ""; + var phone = lead.TryGetProperty("phone", out var p) ? p.GetString() : ""; + + Console.WriteLine($" {name}"); + Console.WriteLine($" Rating: {rating} ({reviews} reviews)"); + if (!string.IsNullOrEmpty(address)) Console.WriteLine($" Address: {address}"); + if (!string.IsNullOrEmpty(phone)) Console.WriteLine($" Phone: {phone}"); + Console.WriteLine(); +} diff --git a/examples/PriceMonitor/PriceMonitor.csproj b/examples/PriceMonitor/PriceMonitor.csproj new file mode 100644 index 0000000..3e18b74 --- /dev/null +++ b/examples/PriceMonitor/PriceMonitor.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/examples/PriceMonitor/Program.cs b/examples/PriceMonitor/Program.cs new file mode 100644 index 0000000..b73cb03 --- /dev/null +++ b/examples/PriceMonitor/Program.cs @@ -0,0 +1,95 @@ +// Price Monitor: track product prices across shopping engines. +// Use case: E-commerce teams monitoring competitor pricing in real time. +// Runs concurrent searches on Google Shopping and Walmart. + +using SerpApi; +using System.Text.Json; + +var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY"); +if (string.IsNullOrEmpty(apiKey)) +{ + Console.WriteLine("Usage: dotnet run -- "); + Console.WriteLine(" or: SERPAPI_KEY=... dotnet run"); + return; +} + +using var client = new SerpApiClient(apiKey, new SerpApiClientOptions +{ + Timeout = TimeSpan.FromSeconds(30) +}); + +var product = "sony wh-1000xm5"; +Console.WriteLine($"Monitoring prices for: \"{product}\"\n"); + +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(20)); + +// Search Google Shopping and Walmart concurrently +var googleShopping = client.SearchAsync(new Dictionary +{ + ["engine"] = "google_shopping", + ["q"] = product +}, cts.Token); + +var walmart = client.SearchAsync(new Dictionary +{ + ["engine"] = "walmart", + ["query"] = product +}, cts.Token); + +try +{ + await Task.WhenAll(googleShopping, walmart); + + // Google Shopping results + Console.WriteLine("=== Google Shopping ==="); + var gResults = await googleShopping; + var shopping = gResults["shopping_results"]; + if (shopping is { } items) + { + foreach (var item in items.EnumerateArray().Take(5)) + { + var title = item.TryGetProperty("title", out var t) ? t.GetString() : "?"; + var price = item.TryGetProperty("extracted_price", out var p) ? $"${p}" : "N/A"; + var source = item.TryGetProperty("source", out var s) ? s.GetString() : ""; + Console.WriteLine($" {price,-8} {source,-15} {title}"); + } + } + else Console.WriteLine(" No results"); + + // Walmart results + Console.WriteLine("\n=== Walmart ==="); + var wResults = await walmart; + var organic = wResults["organic_results"]; + if (organic is { } walmartItems) + { + foreach (var item in walmartItems.EnumerateArray().Take(5)) + { + var title = item.TryGetProperty("title", out var t) ? t.GetString() : "?"; + var price = item.TryGetProperty("primary_offer", out var po) + && po.TryGetProperty("offer_price", out var op) + ? $"${op}" + : "N/A"; + Console.WriteLine($" {price,-8} {title}"); + } + } + else Console.WriteLine(" No results"); +} +catch (OperationCanceledException) when (cts.IsCancellationRequested) +{ + Console.WriteLine("Price search timed out after 20 seconds."); +} +catch (SerpApiException ex) +{ + Console.WriteLine($"Search error: {ex.Message}"); +} +finally +{ + DisposeCompleted(googleShopping); + DisposeCompleted(walmart); +} + +static void DisposeCompleted(Task task) +{ + if (task.Status == TaskStatus.RanToCompletion) + task.Result.Dispose(); +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..baca543 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,30 @@ +# Examples + +Real-world use case examples for the SerpApi .NET SDK. + +## Use Cases + +| Example | Use Case | Engines Used | API Calls | +|---------|----------|-------------|-----------| +| [LeadFinder](LeadFinder/) | Lead generation | Google Maps | 1 | +| [CompetitorTracker](CompetitorTracker/) | SEO & competitive intel | Google Light, Bing | 2 (parallel) | +| [RankTracker](RankTracker/) | SEO rank monitoring | Google Light | 1–3 (pagination) | +| [PriceMonitor](PriceMonitor/) | Price monitoring | Google Shopping, Walmart | 2 (parallel) | +| [AiResearchAgent](AiResearchAgent/) | AI/RAG context gathering | Google Light, Google News, Google Scholar | 3 (parallel) | +| [ContentDiscovery](ContentDiscovery/) | Market research & content gaps | Google News, Google Light | 2 (sequential) | +| [ErrorHandling](ErrorHandling/) | Reliability patterns | — | 0–2 | +| [DependencyInjection](DependencyInjection/) | Enterprise DI integration | Google | 1 | + +## Running + +```bash +export SERPAPI_KEY=your_key_here +cd LeadFinder +dotnet run +``` + +Or pass the key directly: + +```bash +dotnet run -- your_key_here +``` diff --git a/examples/RankTracker/Program.cs b/examples/RankTracker/Program.cs new file mode 100644 index 0000000..b985a4a --- /dev/null +++ b/examples/RankTracker/Program.cs @@ -0,0 +1,61 @@ +// Rank Tracker: monitor keyword positions page by page. +// Use case: SEO teams tracking if a site appears in top 30 for target keywords. +// Demonstrates pagination to scan multiple result pages. + +using SerpApi; +using System.Text.Json; + +var apiKey = args.Length > 0 ? args[0] : Environment.GetEnvironmentVariable("SERPAPI_KEY"); +if (string.IsNullOrEmpty(apiKey)) +{ + Console.WriteLine("Usage: dotnet run -- "); + Console.WriteLine(" or: SERPAPI_KEY=... dotnet run"); + return; +} + +using var client = new SerpApiClient(apiKey); + +var targetDomain = "serpapi.com"; +var keywords = new[] { "search api", "serp scraping", "google search api" }; + +Console.WriteLine($"Tracking rankings for: {targetDomain}\n"); + +foreach (var keyword in keywords) +{ + Console.Write($" \"{keyword}\" ... "); + + var found = false; + var position = 0; + + await foreach (var page in client.SearchPagesAsync( + new Dictionary + { + ["engine"] = "google_light", + ["q"] = keyword, + ["num"] = "10" + }, + maxPages: 3)) + { + using (page) + { + if (page.OrganicResults is not { } results) continue; + + foreach (var result in results.EnumerateArray()) + { + position++; + var link = result.TryGetProperty("link", out var l) ? l.GetString() ?? "" : ""; + if (link.Contains(targetDomain, StringComparison.OrdinalIgnoreCase)) + { + Console.WriteLine($"#{position}"); + found = true; + break; + } + } + } + + if (found) break; + } + + if (!found) + Console.WriteLine($"Not in top {position}"); +} diff --git a/examples/RankTracker/RankTracker.csproj b/examples/RankTracker/RankTracker.csproj new file mode 100644 index 0000000..3e18b74 --- /dev/null +++ b/examples/RankTracker/RankTracker.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + enable + + + + + diff --git a/serpapi-dotnet.slnx b/serpapi-dotnet.slnx new file mode 100644 index 0000000..6e75e3b --- /dev/null +++ b/serpapi-dotnet.slnx @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/serpapi.sln b/serpapi.sln deleted file mode 100644 index 40c19fd..0000000 --- a/serpapi.sln +++ /dev/null @@ -1,48 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26124.0 -MinimumVisualStudioVersion = 15.0.26124.0 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "serpapi", "serpapi\serpapi.csproj", "{B4066941-FAAF-40B3-BE82-C2573877403C}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "test", "test\test.csproj", "{BA5795C3-0649-4BB5-8884-841CF003B05D}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {B4066941-FAAF-40B3-BE82-C2573877403C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {B4066941-FAAF-40B3-BE82-C2573877403C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {B4066941-FAAF-40B3-BE82-C2573877403C}.Debug|x64.ActiveCfg = Debug|Any CPU - {B4066941-FAAF-40B3-BE82-C2573877403C}.Debug|x64.Build.0 = Debug|Any CPU - {B4066941-FAAF-40B3-BE82-C2573877403C}.Debug|x86.ActiveCfg = Debug|Any CPU - {B4066941-FAAF-40B3-BE82-C2573877403C}.Debug|x86.Build.0 = Debug|Any CPU - {B4066941-FAAF-40B3-BE82-C2573877403C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {B4066941-FAAF-40B3-BE82-C2573877403C}.Release|Any CPU.Build.0 = Release|Any CPU - {B4066941-FAAF-40B3-BE82-C2573877403C}.Release|x64.ActiveCfg = Release|Any CPU - {B4066941-FAAF-40B3-BE82-C2573877403C}.Release|x64.Build.0 = Release|Any CPU - {B4066941-FAAF-40B3-BE82-C2573877403C}.Release|x86.ActiveCfg = Release|Any CPU - {B4066941-FAAF-40B3-BE82-C2573877403C}.Release|x86.Build.0 = Release|Any CPU - {BA5795C3-0649-4BB5-8884-841CF003B05D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {BA5795C3-0649-4BB5-8884-841CF003B05D}.Debug|Any CPU.Build.0 = Debug|Any CPU - {BA5795C3-0649-4BB5-8884-841CF003B05D}.Debug|x64.ActiveCfg = Debug|Any CPU - {BA5795C3-0649-4BB5-8884-841CF003B05D}.Debug|x64.Build.0 = Debug|Any CPU - {BA5795C3-0649-4BB5-8884-841CF003B05D}.Debug|x86.ActiveCfg = Debug|Any CPU - {BA5795C3-0649-4BB5-8884-841CF003B05D}.Debug|x86.Build.0 = Debug|Any CPU - {BA5795C3-0649-4BB5-8884-841CF003B05D}.Release|Any CPU.ActiveCfg = Release|Any CPU - {BA5795C3-0649-4BB5-8884-841CF003B05D}.Release|Any CPU.Build.0 = Release|Any CPU - {BA5795C3-0649-4BB5-8884-841CF003B05D}.Release|x64.ActiveCfg = Release|Any CPU - {BA5795C3-0649-4BB5-8884-841CF003B05D}.Release|x64.Build.0 = Release|Any CPU - {BA5795C3-0649-4BB5-8884-841CF003B05D}.Release|x86.ActiveCfg = Release|Any CPU - {BA5795C3-0649-4BB5-8884-841CF003B05D}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection -EndGlobal diff --git a/serpapi/SerpApiClient.cs b/serpapi/SerpApiClient.cs new file mode 100644 index 0000000..05c86b8 --- /dev/null +++ b/serpapi/SerpApiClient.cs @@ -0,0 +1,426 @@ +using System.Net; +using System.Text.Json; + +namespace SerpApi; + +/// +/// Official .NET client for SerpApi. Supports 100+ search engines. +/// +/// +/// +/// using var client = new SerpApiClient("your_api_key"); +/// var result = await client.SearchAsync(new Dictionary<string, string> +/// { +/// ["engine"] = "google", +/// ["q"] = "coffee" +/// }); +/// +/// +public sealed class SerpApiClient : IDisposable +{ + private const string DefaultSource = "dotnet"; + + private readonly HttpClient _httpClient; + private readonly SerpApiClientOptions _options; + private readonly bool _ownsHttpClient; + + /// + /// Creates a new SerpApi client with the specified API key. + /// + /// Your SerpApi API key from https://serpapi.com/manage-api-key + /// Optional configuration overrides. + public SerpApiClient(string apiKey, SerpApiClientOptions? options = null) + { + if (string.IsNullOrWhiteSpace(apiKey)) + throw new SerpApiKeyException("API key must not be empty. Get one at https://serpapi.com/manage-api-key"); + + _options = new SerpApiClientOptions + { + ApiKey = apiKey, + BaseUrl = options?.BaseUrl ?? "https://serpapi.com", + Timeout = options?.Timeout ?? TimeSpan.FromSeconds(60) + }; + _httpClient = new HttpClient { Timeout = _options.Timeout }; + _ownsHttpClient = true; + } + + /// + /// Creates a new SerpApi client using an externally managed HttpClient (for DI/IHttpClientFactory). + /// + /// Pre-configured HttpClient instance. + /// Configuration including API key. + public SerpApiClient(HttpClient httpClient, SerpApiClientOptions options) + { + if (options is null) + throw new ArgumentNullException(nameof(options)); + + if (string.IsNullOrWhiteSpace(options.ApiKey)) + throw new SerpApiKeyException("API key must not be empty. Get one at https://serpapi.com/manage-api-key"); + + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _options = new SerpApiClientOptions + { + ApiKey = options.ApiKey, + BaseUrl = options.BaseUrl, + Timeout = options.Timeout + }; + _ownsHttpClient = false; + } + + /// + /// Execute a search and return parsed JSON results. + /// + /// Search parameters. Must include "engine" key. + /// Cancellation token. + /// Parsed response with convenience accessors. + public async Task SearchAsync( + Dictionary parameters, + CancellationToken cancellationToken = default) + { + if (parameters is null) + throw new ArgumentNullException(nameof(parameters)); + + var url = BuildUrl("/search", parameters, outputJson: true); + var json = await GetStringAsync(url, cancellationToken).ConfigureAwait(false); + var response = ParseResponse(json); + try + { + ThrowIfError(response); + return response; + } + catch + { + response.Dispose(); + throw; + } + } + + /// + /// Execute a search and return raw HTML. + /// + public async Task HtmlAsync( + Dictionary parameters, + CancellationToken cancellationToken = default) + { + if (parameters is null) + throw new ArgumentNullException(nameof(parameters)); + + var url = BuildUrl("/search", parameters, outputJson: false); + return await GetStringAsync(url, cancellationToken).ConfigureAwait(false); + } + + /// + /// Retrieve a previous search result from the archive (free, 0 credits). + /// + /// The search ID from SearchMetadata. + /// Cancellation token. + public async Task SearchArchiveAsync( + string searchId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(searchId)) + throw new ArgumentException("searchId must not be empty.", nameof(searchId)); + + var url = BuildUrl($"/searches/{Uri.EscapeDataString(searchId)}.json", new Dictionary(), outputJson: true); + var json = await GetStringAsync(url, cancellationToken).ConfigureAwait(false); + var response = ParseResponse(json); + try + { + ThrowIfError(response); + return response; + } + catch + { + response.Dispose(); + throw; + } + } + + /// + /// Get account information (0 credits). + /// + public async Task AccountAsync(CancellationToken cancellationToken = default) + { + var url = BuildUrl("/account", new Dictionary(), outputJson: true); + var json = await GetStringAsync(url, cancellationToken).ConfigureAwait(false); + var response = ParseResponse(json); + try + { + ThrowIfError(response); + return response; + } + catch + { + response.Dispose(); + throw; + } + } + + /// + /// Get supported locations matching a query. + /// + /// Location search query (e.g., "Austin, TX"). + /// Max number of results. + /// Cancellation token. + public async Task LocationAsync( + string query, + int limit = 5, + CancellationToken cancellationToken = default) + { + var parameters = new Dictionary + { + ["q"] = query, + ["limit"] = limit.ToString() + }; + + var url = BuildUrl("/locations.json", parameters, outputJson: true, includeOutput: false); + var json = await GetStringAsync(url, cancellationToken).ConfigureAwait(false); + + try + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + catch (JsonException ex) + { + throw new SerpApiException($"Failed to parse location response: {ex.Message}", ex); + } + } + + /// + /// Fetch the next page of results from a previous search response. + /// + /// A previous SerpApiResponse that contains pagination info. + /// Cancellation token. + /// The next page of results, or null if no next page exists. + public async Task NextPageAsync( + SerpApiResponse response, + CancellationToken cancellationToken = default) + { + if (response is null) + throw new ArgumentNullException(nameof(response)); + + var nextUrl = response.NextPageUrl; + if (string.IsNullOrEmpty(nextUrl)) + return null; + + return await FetchPageByUrlAsync(nextUrl!, cancellationToken).ConfigureAwait(false); + } + + private async Task FetchPageByUrlAsync( + string pageUrl, + CancellationToken cancellationToken) + { + // Validate URL origin to prevent SSRF / API key exfiltration + if (!Uri.TryCreate(pageUrl, UriKind.Absolute, out var parsedUri)) + throw new SerpApiException($"Invalid pagination URL: {pageUrl}"); + + var expectedHost = new Uri(_options.BaseUrl).Host; + if (!string.Equals(parsedUri.Host, expectedHost, StringComparison.OrdinalIgnoreCase)) + throw new SerpApiException($"Pagination URL host '{parsedUri.Host}' does not match expected '{expectedHost}'."); + + if (parsedUri.Scheme != "https" && parsedUri.Scheme != "http") + throw new SerpApiException($"Pagination URL must use HTTP(S), got '{parsedUri.Scheme}'."); + + var separator = pageUrl.Contains('?') ? "&" : "?"; + var url = $"{pageUrl}{separator}api_key={Uri.EscapeDataString(_options.ApiKey!)}&source={DefaultSource}"; + var json = await GetStringAsync(url, cancellationToken).ConfigureAwait(false); + var result = ParseResponse(json); + try + { + ThrowIfError(result); + return result; + } + catch + { + result.Dispose(); + throw; + } + } + + /// + /// Enumerate all pages of search results as an async stream. + /// Caller owns each yielded response and should dispose it. + /// + /// Search parameters. + /// Maximum number of pages to retrieve. + /// Cancellation token. + public IAsyncEnumerable SearchPagesAsync( + Dictionary parameters, + int maxPages = 100, + CancellationToken cancellationToken = default) + { + if (parameters is null) + throw new ArgumentNullException(nameof(parameters)); + + if (maxPages <= 0) + throw new ArgumentOutOfRangeException(nameof(maxPages), "maxPages must be at least 1."); + + return SearchPagesIteratorAsync(parameters, maxPages, cancellationToken); + } + + private async IAsyncEnumerable SearchPagesIteratorAsync( + Dictionary parameters, + int maxPages, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + var current = await SearchAsync(parameters, cancellationToken).ConfigureAwait(false); + var nextUrl = current.NextPageUrl; + yield return current; + + for (int i = 1; i < maxPages; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (string.IsNullOrEmpty(nextUrl)) + yield break; + + current = await FetchPageByUrlAsync(nextUrl!, cancellationToken).ConfigureAwait(false); + nextUrl = current.NextPageUrl; + yield return current; + } + } + + // --- Synchronous convenience wrappers --- + // NOTE: Safe in console apps and ASP.NET Core. May deadlock in legacy + // frameworks with a SynchronizationContext (WinForms, WPF, ASP.NET 4.x). + + /// + /// Execute a search synchronously. Prefer for non-blocking usage. + /// + public SerpApiResponse Search(Dictionary parameters) + => Task.Run(() => SearchAsync(parameters)).GetAwaiter().GetResult(); + + /// + /// Get HTML results synchronously. + /// + public string Html(Dictionary parameters) + => Task.Run(() => HtmlAsync(parameters)).GetAwaiter().GetResult(); + + /// + /// Get search archive synchronously. + /// + public SerpApiResponse SearchArchive(string searchId) + => Task.Run(() => SearchArchiveAsync(searchId)).GetAwaiter().GetResult(); + + /// + /// Get account info synchronously. + /// + public SerpApiResponse Account() + => Task.Run(() => AccountAsync()).GetAwaiter().GetResult(); + + /// + /// Get locations synchronously. + /// + public JsonElement Location(string query, int limit = 5) + => Task.Run(() => LocationAsync(query, limit)).GetAwaiter().GetResult(); + + // --- Private helpers --- + + private string BuildUrl( + string endpoint, + Dictionary parameters, + bool outputJson, + bool includeOutput = true) + { + var queryParts = new List(); + + // Add API key + queryParts.Add($"api_key={Uri.EscapeDataString(_options.ApiKey!)}"); + + // Add user parameters + foreach (var kvp in parameters) + { + if (string.Equals(kvp.Key, "api_key", StringComparison.OrdinalIgnoreCase)) + continue; // already added + queryParts.Add($"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value)}"); + } + + // Add output format + if (includeOutput) + queryParts.Add($"output={( outputJson ? "json" : "html" )}"); + + // Add source identifier + queryParts.Add($"source={DefaultSource}"); + + return $"{_options.BaseUrl.TrimEnd('/')}{endpoint}?{string.Join("&", queryParts)}"; + } + + private async Task GetStringAsync(string url, CancellationToken cancellationToken) + { + try + { + using var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false); + + var content = await response.Content.ReadAsStringAsync( +#if NET7_0_OR_GREATER + cancellationToken +#endif + // netstandard2.0: ReadAsStringAsync has no CancellationToken overload + ).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + string errorMessage = content; + try + { + using var doc = JsonDocument.Parse(content); + if (doc.RootElement.TryGetProperty("error", out var errorProp)) + errorMessage = errorProp.GetString() ?? content; + } + catch { /* not JSON, use raw content */ } + + if (errorMessage.IndexOf("Invalid API key", StringComparison.OrdinalIgnoreCase) >= 0) + { + throw new SerpApiKeyException(errorMessage); + } + + throw new SerpApiHttpException((int)response.StatusCode, errorMessage); + } + + return content; + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + throw new SerpApiTimeoutException( + $"Request timed out after {_httpClient.Timeout.TotalSeconds}s", ex); + } + catch (HttpRequestException ex) + { + throw new SerpApiException($"HTTP request failed: {ex.Message}", ex); + } + } + + private static void ThrowIfError(SerpApiResponse response) + { + var error = response["error"]; + if (error != null && error.Value.ValueKind == JsonValueKind.String) + { + var message = error.Value.GetString()!; + if (message.IndexOf("Invalid API key", StringComparison.OrdinalIgnoreCase) >= 0) + { + throw new SerpApiKeyException(message); + } + throw new SerpApiException(message); + } + } + + private static SerpApiResponse ParseResponse(string json) + { + try + { + return new SerpApiResponse(json); + } + catch (JsonException ex) + { + throw new SerpApiException($"Failed to parse response: {ex.Message}", ex); + } + } + + /// + public void Dispose() + { + if (_ownsHttpClient) + _httpClient.Dispose(); + } +} diff --git a/serpapi/SerpApiClientOptions.cs b/serpapi/SerpApiClientOptions.cs new file mode 100644 index 0000000..fd9a680 --- /dev/null +++ b/serpapi/SerpApiClientOptions.cs @@ -0,0 +1,22 @@ +namespace SerpApi; + +/// +/// Configuration options for . +/// +public sealed class SerpApiClientOptions +{ + /// + /// SerpApi API key. Obtain from https://serpapi.com/manage-api-key + /// + public string? ApiKey { get; set; } + + /// + /// Base URL for the SerpApi service. Defaults to https://serpapi.com. + /// + public string BaseUrl { get; set; } = "https://serpapi.com"; + + /// + /// HTTP request timeout. Defaults to 60 seconds. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(60); +} diff --git a/serpapi/SerpApiExceptions.cs b/serpapi/SerpApiExceptions.cs new file mode 100644 index 0000000..964360e --- /dev/null +++ b/serpapi/SerpApiExceptions.cs @@ -0,0 +1,49 @@ +namespace SerpApi; + +/// +/// Base exception for all SerpApi errors. +/// +public class SerpApiException : Exception +{ + /// + public SerpApiException(string message) : base(message) { } + /// + public SerpApiException(string message, Exception innerException) : base(message, innerException) { } +} + +/// +/// Thrown when the API returns an HTTP error response. +/// +public class SerpApiHttpException : SerpApiException +{ + /// + /// HTTP status code returned by the API. + /// + public int StatusCode { get; } + + /// + public SerpApiHttpException(int statusCode, string message) + : base(message) + { + StatusCode = statusCode; + } +} + +/// +/// Thrown when the API key is missing or invalid. +/// +public class SerpApiKeyException : SerpApiException +{ + /// + public SerpApiKeyException(string message) : base(message) { } +} + +/// +/// Thrown when an HTTP request times out. +/// +public class SerpApiTimeoutException : SerpApiException +{ + /// + public SerpApiTimeoutException(string message, Exception innerException) + : base(message, innerException) { } +} diff --git a/serpapi/SerpApiResponse.cs b/serpapi/SerpApiResponse.cs new file mode 100644 index 0000000..e133a9d --- /dev/null +++ b/serpapi/SerpApiResponse.cs @@ -0,0 +1,113 @@ +using System.Text.Json; + +namespace SerpApi; + +/// +/// Wraps a SerpApi JSON response, providing dictionary-like access and convenience properties. +/// +public sealed class SerpApiResponse : IDisposable +{ + private readonly JsonDocument _document; + private readonly JsonElement _root; + private readonly string _rawJson; + + internal SerpApiResponse(string json) + { + _rawJson = json; + _document = JsonDocument.Parse(json); + _root = _document.RootElement; + } + + /// + /// Access a top-level property by key. Returns null if the key does not exist. + /// + public JsonElement? this[string key] + { + get + { + if (_root.TryGetProperty(key, out var value)) + return value; + return null; + } + } + + /// + /// The search_metadata object from the response. + /// + public JsonElement? SearchMetadata => this["search_metadata"]; + + /// + /// The search_parameters object from the response. + /// + public JsonElement? SearchParameters => this["search_parameters"]; + + /// + /// The organic_results array from the response, if present. + /// + public JsonElement? OrganicResults => this["organic_results"]; + + /// + /// The search ID from search_metadata, used for archive lookups. + /// + public string? SearchId + { + get + { + var metadata = SearchMetadata; + if (metadata?.TryGetProperty("id", out var id) == true) + return id.GetString(); + return null; + } + } + + /// + /// Pagination info from serpapi_pagination, if present. + /// + public JsonElement? Pagination => this["serpapi_pagination"]; + + /// + /// URL for the next page of results, if available. + /// + public string? NextPageUrl + { + get + { + var pagination = Pagination; + if (pagination?.TryGetProperty("next", out var next) == true) + return next.GetString(); + return null; + } + } + + /// + /// Returns the raw JSON string of the response. + /// + public string RawJson => _rawJson; + + /// + /// Deserialize the entire response to a specific type. + /// + public T? As() => JsonSerializer.Deserialize(_rawJson); + + /// + /// Deserialize a specific property to a type. + /// + public T? GetProperty(string key) + { + var element = this[key]; + if (element == null) + return default; + return JsonSerializer.Deserialize(element.Value.GetRawText()); + } + + /// + /// Get the root JSON element for advanced traversal. + /// + public JsonElement Root => _root; + + /// + public override string ToString() => _rawJson; + + /// + public void Dispose() => _document.Dispose(); +} diff --git a/serpapi/SerpApiServiceCollectionExtensions.cs b/serpapi/SerpApiServiceCollectionExtensions.cs new file mode 100644 index 0000000..3a480f8 --- /dev/null +++ b/serpapi/SerpApiServiceCollectionExtensions.cs @@ -0,0 +1,43 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace SerpApi; + +/// +/// Extension methods for registering SerpApiClient with Microsoft.Extensions.DependencyInjection. +/// +public static class SerpApiServiceCollectionExtensions +{ + /// + /// Adds SerpApiClient to the service collection with IHttpClientFactory integration. + /// + /// The service collection. + /// Action to configure SerpApiClientOptions. + /// The IHttpClientBuilder for further configuration (e.g., Polly policies). + /// + /// + /// services.AddSerpApi(options => + /// { + /// options.ApiKey = "your_api_key"; + /// options.Timeout = TimeSpan.FromSeconds(30); + /// }); + /// + /// + public static IHttpClientBuilder AddSerpApi( + this IServiceCollection services, + Action configure) + { + services.Configure(configure); + + return services.AddHttpClient((sp, httpClient) => + { + var options = sp.GetRequiredService>().Value; + httpClient.Timeout = options.Timeout; + }) + .AddTypedClient((httpClient, sp) => + { + var options = sp.GetRequiredService>().Value; + return new SerpApiClient(httpClient, options); + }); + } +} diff --git a/serpapi/serpapi.cs b/serpapi/serpapi.cs deleted file mode 100644 index 29ece92..0000000 --- a/serpapi/serpapi.cs +++ /dev/null @@ -1,225 +0,0 @@ -using System; -using System.Net.Http; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Threading.Tasks; -using System.Text.RegularExpressions; - -/*** - * Client for SerpApi.com - */ -namespace SerpApi -{ - public class SerpApi - { - const string JSON_FORMAT = "json"; - const string HTML_FORMAT = "html"; - const string HOST = "https://serpapi.com"; - - // contextual parameter provided to SerpApi - public Hashtable defaultParameter; - - // Core HTTP search - public HttpClient client; - - public SerpApi(Hashtable parameter = null) - { - // assign query parameter - if(parameter == null) { - parameter = new Hashtable(); - } - this.defaultParameter = parameter; - - // initialize clean - this.client = new HttpClient(); - - // set default timeout to 60s - this.setTimeoutSeconds(60); - } - - /*** - * Set HTTP timeout in seconds - */ - public void setTimeoutSeconds(int seconds) - { - this.client.Timeout = TimeSpan.FromSeconds(seconds); - } - - /*** - * Get Json result - */ - public JObject search(Hashtable parameter) - { - return json("/search", parameter); - } - - /*** - * Get search archive for JSON results - */ - public JObject searchArchive(string searchId) - { - return json("/searches/" + searchId + ".json", new Hashtable()); - } - - /*** - * Get search HTML results - */ - public string html(Hashtable parameter) - { - return get("/search", parameter, false); - } - - /*** - * Get user account - */ - public JObject account(string apiKey = "") - { - Hashtable parameter = new Hashtable(); - if(apiKey != "") { - parameter.Add("api_key", apiKey); - } - return json("/account", parameter); - } - - /*** - * Get location using location API - */ - public JArray location(Hashtable parameter) - { - // get json result - string buffer = get("/locations.json", parameter, true); - // parse json response (ignore http response status) - try { - JArray data = JArray.Parse(buffer); - return data; - } - catch - { - // report error if something went wrong - JObject data = JObject.Parse(buffer); - if (data.ContainsKey("error")) - { - throw new ClientException(data.GetValue("error").ToString()); - } - throw new ClientException("oops no error found when parsing: " + buffer); - } - } - - public string get(string endpoint, Hashtable parameter, bool jsonEnabled) - { - string url = createUrl(endpoint, parameter, jsonEnabled); - // run asynchonous http query (.net framework implementation) - Task queryTask = createQuery(url, jsonEnabled); - // block until http query is completed - queryTask.ConfigureAwait(true); - // parse result into json - return queryTask.Result; - } - - - public JObject json(string uri, Hashtable parameter) - { - // get json result - string buffer = get(uri, parameter, true); - // parse json response (ignore http response status) - JObject data = JObject.Parse(buffer); - // report error if something went wrong - if (data.ContainsKey("error")) - { - throw new ClientException(data.GetValue("error").ToString()); - } - return data; - } - - // Convert parmaterContext into URL request. - // - // note: - // - C# URL encoding is pretty buggy and the API provides method which are not functional. - // - System.Web.HttpUtility.UrlEncode breaks if apply the full URL - /// - public string createUrl(string endpoint, Hashtable parameter, bool jsonEnabled) - { - // merge parameter - Hashtable table = new Hashtable(); - // default parameter - foreach(DictionaryEntry e in this.defaultParameter) - { - if (!parameter.ContainsKey(e.Key)) { - table.Add(e.Key, e.Value); - } - } - // user parameter override - foreach(DictionaryEntry e in parameter) - { - table.Add(e.Key, e.Value); - } - - string s = ""; - foreach (DictionaryEntry entry in table) - { - if (s != "") - { - s += "&"; - } - // encode each value in case of special character - s += entry.Key + "=" + System.Web.HttpUtility.UrlEncode((string)entry.Value, System.Text.Encoding.UTF8); - } - - // append output format - s += "&output=" + (jsonEnabled ? JSON_FORMAT : HTML_FORMAT); - - // append source language - s += "&source=dotnet"; - - return HOST + endpoint + "?" + s; - } - - /*** - * Close socket connection associated to HTTP search - */ - public void Close() - { - this.client.Dispose(); - } - - private async Task createQuery(string url, bool jsonEnabled) - { - // display url for debug: - //Console.WriteLine("url: " + url); - try - { - HttpResponseMessage response = await this.client.GetAsync(url); - var content = await response.Content.ReadAsStringAsync(); - // return raw JSON - if (jsonEnabled) - { - response.Dispose(); - return content; - } - // HTML response or other - if (response.IsSuccessStatusCode) - { - response.Dispose(); - return content; - } - else - { - response.Dispose(); - throw new ClientException("Http request fail: " + content); - } - } - catch (Exception ex) - { - // handle HTTP issues - throw new ClientException(ex.ToString()); - } - throw new ClientException("Oops something went very wrong"); - } - } - - public class ClientException : Exception - { - public ClientException(string message) : base(message) { } - } - -} diff --git a/serpapi/serpapi.csproj b/serpapi/serpapi.csproj index 91f79c6..7151bf0 100644 --- a/serpapi/serpapi.csproj +++ b/serpapi/serpapi.csproj @@ -1,34 +1,45 @@ - serpapi + netstandard2.0;net7.0;net8.0;net9.0;net10.0 + SerpApi serpapi - 1.0.0 - 1.0.0 - victor@serpapi.com - SerpApi.LLC - true - true + 2.0.0 + SerpApi + SerpApi LLC MIT - README.md - serpapi - Integrate search data into your Ruby application. This library is the official wrapper for SerpApi. SerpApi supports Google, Google Maps, Google Shopping, Baidu, Yandex, Yahoo, eBay, App Stores, and more. - Search,Engine,Google,Search,Result,JSON,Scraping,Scrape,Crawl,Baidu,Bing,eBay,Walmart,Yandex,YouTube,ML,Machine,learning - SerpApi LLC 2019-2024 - false - true - true + SerpApi + Official .NET client for SerpApi — scrape and parse search engine results. Supports Google, Bing, Baidu, Yandex, Yahoo, DuckDuckGo, eBay, Walmart, YouTube, and 100+ engines. + SerpApi;Search;Google;Bing;SERP;Scraping;API;SEO;Baidu;Yandex;DuckDuckGo;YouTube;eBay;Walmart + SerpApi LLC 2019-2026 https://github.com/serpapi/serpapi-dotnet git - true + README.md + true + enable + enable + latest + true + true + true + + + true - + + + - + + + + + + - + diff --git a/test/DependencyInjectionTests.cs b/test/DependencyInjectionTests.cs new file mode 100644 index 0000000..5f02bd9 --- /dev/null +++ b/test/DependencyInjectionTests.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace SerpApi.Tests; + +public class DependencyInjectionTests +{ + [Fact] + public void AddSerpApi_RegistersClient() + { + var services = new ServiceCollection(); + services.AddSerpApi(options => + { + options.ApiKey = "test_key"; + options.Timeout = TimeSpan.FromSeconds(30); + }); + + var provider = services.BuildServiceProvider(); + var client = provider.GetRequiredService(); + Assert.NotNull(client); + } + + [Fact] + public void AddSerpApi_ReturnsHttpClientBuilder() + { + var services = new ServiceCollection(); + var builder = services.AddSerpApi(options => + { + options.ApiKey = "test_key"; + }); + + Assert.NotNull(builder); + } +} diff --git a/test/GlobalUsings.cs b/test/GlobalUsings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/test/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/test/IntegrationTests.cs b/test/IntegrationTests.cs new file mode 100644 index 0000000..02b841b --- /dev/null +++ b/test/IntegrationTests.cs @@ -0,0 +1,462 @@ +using System.Text.Json; +using Xunit; + +namespace SerpApi.Tests; + +/// +/// Integration tests that hit the real SerpApi. +/// Skipped at runtime unless SERPAPI_API_KEY env var is set. +/// Run with: dotnet test --filter "Category=Integration" +/// +/// Uses async=true + no_cache=true: fires all searches concurrently at fixture init, +/// polls results in parallel. Each test asserts on pre-fetched results (< 1ms). +/// Slow tests (pagination, HTML) are in separate classes for xUnit parallelism. +/// +public class IntegrationFixture : IAsyncLifetime +{ + public SerpApiClient? Client { get; private set; } + public Dictionary Results { get; } = new(); + + private static readonly Dictionary> Searches = new() + { + ["google"] = new() { ["engine"] = "google", ["q"] = "coffee", ["location"] = "Austin, Texas" }, + ["google_archive"] = new() { ["engine"] = "google", ["q"] = "serpapi test archive" }, + ["bing"] = new() { ["engine"] = "bing", ["q"] = "coffee" }, + ["google_maps"] = new() { ["engine"] = "google_maps", ["q"] = "pizza", ["ll"] = "@40.7455096,-74.0083012,15.1z", ["type"] = "search" }, + ["youtube"] = new() { ["engine"] = "youtube", ["search_query"] = "coffee" }, + ["google_shopping"] = new() { ["engine"] = "google_shopping", ["q"] = "coffee maker" }, + ["google_news"] = new() { ["engine"] = "google_news", ["q"] = "technology" }, + ["google_scholar"] = new() { ["engine"] = "google_scholar", ["q"] = "machine learning" }, + ["google_jobs"] = new() { ["engine"] = "google_jobs", ["q"] = "software engineer" }, + ["duckduckgo"] = new() { ["engine"] = "duckduckgo", ["q"] = "coffee" }, + ["yahoo"] = new() { ["engine"] = "yahoo", ["p"] = "coffee" }, + ["baidu"] = new() { ["engine"] = "baidu", ["q"] = "coffee" }, + ["yandex"] = new() { ["engine"] = "yandex", ["text"] = "coffee" }, + ["apple_app_store"] = new() { ["engine"] = "apple_app_store", ["term"] = "weather" }, + ["walmart"] = new() { ["engine"] = "walmart", ["query"] = "coffee" }, + ["ebay"] = new() { ["engine"] = "ebay", ["_nkw"] = "coffee" }, + ["naver"] = new() { ["engine"] = "naver", ["query"] = "coffee" }, + ["google_images"] = new() { ["engine"] = "google_images", ["q"] = "coffee", ["tbm"] = "isch" }, + ["google_events"] = new() { ["engine"] = "google_events", ["q"] = "coffee" }, + ["google_autocomplete"] = new() { ["engine"] = "google_autocomplete", ["q"] = "coffee" }, + ["google_local_services"] = new() { ["engine"] = "google_local_services", ["q"] = "electrician", ["data_cid"] = "6745062158417646970" }, + ["google_reverse_image"] = new() { ["engine"] = "google_reverse_image", ["image_url"] = "https://i.imgur.com/HBrB8p0.png" }, + ["google_play"] = new() { ["engine"] = "google_play", ["q"] = "kite", ["store"] = "apps" }, + ["home_depot"] = new() { ["engine"] = "home_depot", ["q"] = "table" }, + }; + + public async Task InitializeAsync() + { + var apiKey = Environment.GetEnvironmentVariable("SERPAPI_API_KEY") + ?? Environment.GetEnvironmentVariable("API_KEY"); + if (string.IsNullOrEmpty(apiKey)) return; + + Client = new SerpApiClient(apiKey); + + var searchIds = new Dictionary(); + var fireTasks = Searches.Select(async kv => + { + try + { + var parameters = new Dictionary(kv.Value) + { + ["async"] = "true", + ["no_cache"] = "true" + }; + var queued = await Client.SearchAsync(parameters); + lock (searchIds) { searchIds[kv.Key] = queued.SearchId!; } + queued.Dispose(); + } + catch + { + // Individual engine failure should not crash the fixture + } + }); + await Task.WhenAll(fireTasks); + + var pollTasks = searchIds.Select(async kv => + { + try + { + for (var i = 0; i < 240; i++) + { + await Task.Delay(250); + var result = await Client.SearchArchiveAsync(kv.Value); + var status = result.SearchMetadata?.GetProperty("status").GetString(); + if (status == "Success") + { + lock (Results) { Results[kv.Key] = result; } + return; + } + result.Dispose(); + } + } + catch + { + // Individual engine poll failure should not crash the fixture + } + }); + await Task.WhenAll(pollTasks); + } + + public Task DisposeAsync() + { + foreach (var r in Results.Values) r.Dispose(); + Client?.Dispose(); + return Task.CompletedTask; + } +} + +// --- Prefetched search tests (all < 1ms, share one fixture) --- + +[Trait("Category", "Integration")] +public class SearchIntegrationTests : IClassFixture +{ + private readonly IntegrationFixture _f; + public SearchIntegrationTests(IntegrationFixture fixture) => _f = fixture; + + private SerpApiResponse GetResult(string key) + { + Skip.If(_f.Client is null, "SERPAPI_API_KEY not set"); + Skip.If(!_f.Results.ContainsKey(key), $"Engine '{key}' not available"); + return _f.Results[key]; + } + + [SkippableFact] + public void Google_ReturnsOrganicResults() + { + var result = GetResult("google"); + + Assert.NotNull(result.SearchMetadata); + Assert.NotNull(result.SearchId); + Assert.NotNull(result.OrganicResults); + Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0); + + var first = result.OrganicResults!.Value[0]; + Assert.True(first.TryGetProperty("title", out _)); + Assert.True(first.TryGetProperty("link", out _)); + } + + [SkippableFact] + public void Bing_ReturnsOrganicResults() + { + var result = GetResult("bing"); + Assert.NotNull(result.OrganicResults); + Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void GoogleMaps_ReturnsLocalResults() + { + var result = GetResult("google_maps"); + var localResults = result["local_results"]; + Assert.NotNull(localResults); + Assert.True(localResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void YouTube_ReturnsVideoResults() + { + var result = GetResult("youtube"); + var videoResults = result["video_results"]; + Assert.NotNull(videoResults); + Assert.True(videoResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public async Task ArchiveRoundTrip_SearchThenRetrieve() + { + var original = GetResult("google_archive"); + var searchId = original.SearchId; + Assert.NotNull(searchId); + + var archived = await _f.Client!.SearchArchiveAsync(searchId!); + Assert.NotNull(archived.SearchId); + Assert.Equal(searchId, archived.SearchId); + } + + [SkippableFact] + public void GoogleShopping_ReturnsShoppingResults() + { + var result = GetResult("google_shopping"); + var shoppingResults = result["shopping_results"]; + Assert.NotNull(shoppingResults); + Assert.True(shoppingResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void GoogleNews_ReturnsNewsResults() + { + var result = GetResult("google_news"); + var newsResults = result["news_results"]; + Assert.NotNull(newsResults); + Assert.True(newsResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void GoogleScholar_ReturnsOrganicResults() + { + var result = GetResult("google_scholar"); + Assert.NotNull(result.OrganicResults); + Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void GoogleJobs_ReturnsJobsResults() + { + var result = GetResult("google_jobs"); + var jobsResults = result["jobs_results"]; + Assert.NotNull(jobsResults); + Assert.True(jobsResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void DuckDuckGo_ReturnsOrganicResults() + { + var result = GetResult("duckduckgo"); + Assert.NotNull(result.OrganicResults); + Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void Yahoo_ReturnsOrganicResults() + { + var result = GetResult("yahoo"); + Assert.NotNull(result.OrganicResults); + Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void Baidu_ReturnsOrganicResults() + { + var result = GetResult("baidu"); + Assert.NotNull(result.OrganicResults); + Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void Yandex_ReturnsOrganicResults() + { + var result = GetResult("yandex"); + Assert.NotNull(result.OrganicResults); + Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void AppleAppStore_ReturnsOrganicResults() + { + var result = GetResult("apple_app_store"); + Assert.NotNull(result.OrganicResults); + Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void Walmart_ReturnsOrganicResults() + { + var result = GetResult("walmart"); + Assert.NotNull(result.OrganicResults); + Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void Ebay_ReturnsOrganicResults() + { + var result = GetResult("ebay"); + Assert.NotNull(result.OrganicResults); + Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void Naver_ReturnsResults() + { + var result = GetResult("naver"); + var adsResults = result["ads_results"]; + Assert.NotNull(adsResults); + Assert.True(adsResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void GoogleImages_ReturnsImageResults() + { + var result = GetResult("google_images"); + var imageResults = result["images_results"]; + Assert.NotNull(imageResults); + Assert.True(imageResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void GoogleEvents_ReturnsEventResults() + { + var result = GetResult("google_events"); + var eventsResults = result["events_results"]; + Assert.NotNull(eventsResults); + Assert.True(eventsResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void GoogleAutocomplete_ReturnsSuggestions() + { + var result = GetResult("google_autocomplete"); + var suggestions = result["suggestions"]; + Assert.NotNull(suggestions); + Assert.True(suggestions!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void GoogleLocalServices_ReturnsLocalAds() + { + var result = GetResult("google_local_services"); + var localAds = result["local_ads"]; + Assert.NotNull(localAds); + Assert.True(localAds!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void GoogleReverseImage_ReturnsImageResults() + { + var result = GetResult("google_reverse_image"); + var imageResults = result["image_results"]; + Assert.NotNull(imageResults); + } + + [SkippableFact] + public void GooglePlay_ReturnsOrganicResults() + { + var result = GetResult("google_play"); + Assert.NotNull(result.OrganicResults); + Assert.True(result.OrganicResults!.Value.GetArrayLength() > 0); + } + + [SkippableFact] + public void HomeDepot_ReturnsProducts() + { + var result = GetResult("home_depot"); + var products = result["products"]; + Assert.NotNull(products); + Assert.True(products!.Value.GetArrayLength() > 0); + } +} + +// --- Each slow test in its own class for parallel execution --- + +[Trait("Category", "Integration")] +public class HtmlIntegrationTest +{ + [SkippableFact] + public async Task Google_HtmlEndpoint_ReturnsHtml() + { + var apiKey = Environment.GetEnvironmentVariable("SERPAPI_API_KEY") + ?? Environment.GetEnvironmentVariable("API_KEY"); + Skip.If(string.IsNullOrEmpty(apiKey), "SERPAPI_API_KEY not set"); + + using var client = new SerpApiClient(apiKey!); + var html = await client.HtmlAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "coffee", + ["no_cache"] = "true" + }); + + Assert.Contains(" 100); + } +} + +[Trait("Category", "Integration")] +public class PaginationNextPageTest +{ + [SkippableFact] + public async Task Pagination_NextPageWorks() + { + var apiKey = Environment.GetEnvironmentVariable("SERPAPI_API_KEY") + ?? Environment.GetEnvironmentVariable("API_KEY"); + Skip.If(string.IsNullOrEmpty(apiKey), "SERPAPI_API_KEY not set"); + + using var client = new SerpApiClient(apiKey!); + var firstPage = await client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "coffee shops", + ["num"] = "10", + ["no_cache"] = "true" + }); + + Assert.NotNull(firstPage.NextPageUrl); + + var secondPage = await client.NextPageAsync(firstPage); + Assert.NotNull(secondPage); + Assert.NotNull(secondPage!.OrganicResults); + Assert.True(secondPage.OrganicResults!.Value.GetArrayLength() > 0); + } +} + +[Trait("Category", "Integration")] +public class PaginationIteratorTest +{ + [SkippableFact] + public async Task SearchPagesAsync_IteratesMultiplePages() + { + var apiKey = Environment.GetEnvironmentVariable("SERPAPI_API_KEY") + ?? Environment.GetEnvironmentVariable("API_KEY"); + Skip.If(string.IsNullOrEmpty(apiKey), "SERPAPI_API_KEY not set"); + + using var client = new SerpApiClient(apiKey!); + var pages = new List(); + await foreach (var page in client.SearchPagesAsync( + new Dictionary + { + ["engine"] = "google", + ["q"] = "best coffee beans", + ["num"] = "10", + ["no_cache"] = "true" + }, + maxPages: 2)) + { + pages.Add(page); + } + + Assert.True(pages.Count >= 2, $"Expected at least 2 pages, got {pages.Count}"); + foreach (var page in pages) + page.Dispose(); + } +} + +[Trait("Category", "Integration")] +public class LocationIntegrationTest +{ + [SkippableFact] + public async Task Location_ReturnsResults() + { + var apiKey = Environment.GetEnvironmentVariable("SERPAPI_API_KEY") + ?? Environment.GetEnvironmentVariable("API_KEY"); + Skip.If(string.IsNullOrEmpty(apiKey), "SERPAPI_API_KEY not set"); + + using var client = new SerpApiClient(apiKey!); + var result = await client.LocationAsync("Austin, TX", limit: 3); + + Assert.Equal(JsonValueKind.Array, result.ValueKind); + Assert.True(result.GetArrayLength() > 0); + + var first = result[0]; + Assert.True(first.TryGetProperty("name", out _)); + Assert.True(first.TryGetProperty("google_id", out _)); + } +} + +[Trait("Category", "Integration")] +public class AccountIntegrationTest +{ + [SkippableFact] + public async Task Account_ReturnsAccountInfo() + { + var apiKey = Environment.GetEnvironmentVariable("SERPAPI_API_KEY") + ?? Environment.GetEnvironmentVariable("API_KEY"); + Skip.If(string.IsNullOrEmpty(apiKey), "SERPAPI_API_KEY not set"); + + using var client = new SerpApiClient(apiKey!); + var result = await client.AccountAsync(); + + Assert.NotNull(result["account_id"]); + Assert.NotNull(result["api_key"]); + } +} diff --git a/test/MockHttpHandler.cs b/test/MockHttpHandler.cs new file mode 100644 index 0000000..7abd9a7 --- /dev/null +++ b/test/MockHttpHandler.cs @@ -0,0 +1,23 @@ +using System.Net; + +namespace SerpApi.Tests; + +/// +/// Mock HTTP message handler for unit tests. +/// +internal class MockHttpHandler : HttpMessageHandler +{ + private readonly Func> _handler; + + public MockHttpHandler(Func> handler) + { + _handler = handler; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + return _handler(request, cancellationToken); + } +} diff --git a/test/SerpApiClientTests.cs b/test/SerpApiClientTests.cs new file mode 100644 index 0000000..ae40c84 --- /dev/null +++ b/test/SerpApiClientTests.cs @@ -0,0 +1,975 @@ +using System.Net; +using System.Text.Json; + +namespace SerpApi.Tests; + +public class SerpApiClientTests +{ + [Fact] + public void Constructor_ThrowsOnEmptyApiKey() + { + Assert.Throws(() => new SerpApiClient("")); + Assert.Throws(() => new SerpApiClient(" ")); + } + + [Fact] + public void Constructor_AcceptsValidApiKey() + { + using var client = new SerpApiClient("test_key_123"); + Assert.NotNull(client); + } + + [Fact] + public void Constructor_WithOptions_SetsTimeout() + { + var options = new SerpApiClientOptions { Timeout = TimeSpan.FromSeconds(10) }; + using var client = new SerpApiClient("key", options); + Assert.NotNull(client); + } + + [Fact] + public void Constructor_HttpClient_ThrowsOnEmptyApiKey() + { + var httpClient = new HttpClient(); + var options = new SerpApiClientOptions { ApiKey = "" }; + Assert.Throws(() => new SerpApiClient(httpClient, options)); + } + + [Fact] + public void Constructor_HttpClient_ThrowsOnNullOptions() + { + using var httpClient = new HttpClient(); + + var exception = Assert.Throws( + () => new SerpApiClient(httpClient, null!)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact] + public async Task SearchAsync_BuildsCorrectUrl() + { + string? capturedUrl = null; + var handler = new MockHttpHandler((request, ct) => + { + capturedUrl = request.RequestUri?.AbsoluteUri; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"search_metadata\":{\"id\":\"abc\"},\"organic_results\":[]}") + }); + }); + + var httpClient = new HttpClient(handler); + var options = new SerpApiClientOptions { ApiKey = "test_key" }; + using var client = new SerpApiClient(httpClient, options); + + await client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "coffee", + ["location"] = "Austin, Texas" + }); + + Assert.NotNull(capturedUrl); + Assert.Contains("api_key=test_key", capturedUrl); + Assert.Contains("engine=google", capturedUrl); + Assert.Contains("q=coffee", capturedUrl); + Assert.Contains("location=Austin", capturedUrl); + Assert.Contains("output=json", capturedUrl); + Assert.Contains("source=dotnet", capturedUrl); + Assert.StartsWith("https://serpapi.com/search?", capturedUrl); + } + + [Fact] + public async Task SearchAsync_ReturnsResponse() + { + var json = """ + { + "search_metadata": {"id": "abc123", "status": "Success"}, + "search_parameters": {"engine": "google", "q": "test"}, + "organic_results": [ + {"position": 1, "title": "Test Result", "link": "https://example.com"} + ] + } + """; + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json) + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var result = await client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + }); + + Assert.Equal("abc123", result.SearchId); + Assert.NotNull(result.OrganicResults); + Assert.Equal(JsonValueKind.Array, result.OrganicResults!.Value.ValueKind); + Assert.Equal(1, result.OrganicResults!.Value.GetArrayLength()); + } + + [Fact] + public async Task SearchAsync_ThrowsOnApiError() + { + var json = """{"error": "Invalid API key. Your API key should be here: https://serpapi.com/manage-api-key"}"""; + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json) + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "bad_key" }); + + await Assert.ThrowsAsync(() => + client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + })); + } + + [Fact] + public async Task SearchAsync_ThrowsOnHttpError() + { + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.TooManyRequests) + { + Content = new StringContent("""{"error": "Rate limit exceeded"}""") + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var ex = await Assert.ThrowsAsync(() => + client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + })); + + Assert.Equal(429, ex.StatusCode); + Assert.Contains("Rate limit", ex.Message); + } + + [Fact] + public async Task SearchAsync_ThrowsKeyExceptionOnInvalidKeyHttpError() + { + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent("""{"error": "Invalid API key"}""") + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "invalid_key" }); + + var exception = await Assert.ThrowsAsync(() => + client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + })); + + Assert.Equal("Invalid API key", exception.Message); + } + + [Fact] + public async Task SearchAsync_ThrowsHttpExceptionOnQuotaHttpError() + { + const string message = "You have run out of searches. Upgrade your API key plan."; + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.TooManyRequests) + { + Content = new StringContent($"{{\"error\":\"{message}\"}}") + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var exception = await Assert.ThrowsAsync(() => + client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + })); + + Assert.Equal(429, exception.StatusCode); + Assert.Equal(message, exception.Message); + } + + [Fact] + public async Task SearchAsync_ThrowsOnTimeout() + { + var handler = new MockHttpHandler(async (_, ct) => + { + await Task.Delay(TimeSpan.FromSeconds(10), ct); + return new HttpResponseMessage(HttpStatusCode.OK); + }); + + var httpClient = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(50) }; + using var client = new SerpApiClient(httpClient, + new SerpApiClientOptions { ApiKey = "key", Timeout = TimeSpan.FromSeconds(30) }); + + var exception = await Assert.ThrowsAsync(() => + client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + })); + + Assert.Contains("0.05s", exception.Message); + } + + [Fact] + public async Task HtmlAsync_ReturnsHtmlString() + { + var html = "Search results"; + var handler = new MockHttpHandler((request, _) => + { + Assert.Contains("output=html", request.RequestUri!.ToString()); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(html) + }); + }); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var result = await client.HtmlAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + }); + + Assert.Equal(html, result); + } + + [Fact] + public async Task SearchArchiveAsync_BuildsCorrectUrl() + { + string? capturedUrl = null; + var handler = new MockHttpHandler((request, _) => + { + capturedUrl = request.RequestUri?.ToString(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"search_metadata":{"id":"abc123"}}""") + }); + }); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + await client.SearchArchiveAsync("abc123"); + Assert.Contains("/searches/abc123.json", capturedUrl!); + } + + [Fact] + public async Task SearchArchiveAsync_ThrowsOnEmptyId() + { + using var client = new SerpApiClient("key"); + await Assert.ThrowsAsync(() => client.SearchArchiveAsync("")); + } + + [Fact] + public async Task AccountAsync_ReturnsAccountInfo() + { + var json = """{"account_id":"123","api_key":"key","plan_id":"free"}"""; + var handler = new MockHttpHandler((request, _) => + { + Assert.Contains("/account", request.RequestUri!.ToString()); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json) + }); + }); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var result = await client.AccountAsync(); + Assert.Equal("123", result["account_id"]!.Value.GetString()); + } + + [Fact] + public async Task LocationAsync_ReturnsArray() + { + var json = """[{"id":"585069bdee19ad271e9bc072","name":"Austin, TX","google_id":200635}]"""; + var handler = new MockHttpHandler((request, _) => + { + var url = request.RequestUri!.ToString(); + Assert.Contains("/locations.json", url); + Assert.Contains("q=Austin", url); + Assert.Contains("limit=3", url); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json) + }); + }); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var result = await client.LocationAsync("Austin", limit: 3); + Assert.Equal(JsonValueKind.Array, result.ValueKind); + Assert.Equal(1, result.GetArrayLength()); + } + + [Fact] + public async Task NextPageAsync_ReturnsNullWhenNoPagination() + { + var json = """{"search_metadata":{"id":"abc"},"organic_results":[]}"""; + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json) + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var firstPage = await client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + }); + + var nextPage = await client.NextPageAsync(firstPage); + Assert.Null(nextPage); + } + + [Fact] + public async Task NextPageAsync_ThrowsOnNullResponse() + { + using var client = new SerpApiClient("key"); + + var exception = await Assert.ThrowsAsync(() => + client.NextPageAsync(null!)); + + Assert.Equal("response", exception.ParamName); + } + + [Fact] + public async Task NextPageAsync_FollowsPaginationUrl() + { + var json = """ + { + "search_metadata":{"id":"abc"}, + "organic_results":[], + "serpapi_pagination":{"next":"https://serpapi.com/search?q=test&start=10"} + } + """; + string? capturedUrl = null; + var handler = new MockHttpHandler((request, _) => + { + capturedUrl = request.RequestUri?.AbsoluteUri; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"search_metadata":{"id":"def"},"organic_results":[]}""") + }); + }); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var firstPage = new SerpApiResponse(json); + var nextPage = await client.NextPageAsync(firstPage); + + Assert.NotNull(nextPage); + Assert.NotNull(capturedUrl); + Assert.Contains("start=10", capturedUrl!); + Assert.Contains("api_key=key", capturedUrl!); + Assert.Contains("source=dotnet", capturedUrl!); + } + + [Fact] + public async Task SearchAsync_SpecialCharactersEncodedCorrectly() + { + string? capturedUrl = null; + var handler = new MockHttpHandler((request, _) => + { + capturedUrl = request.RequestUri?.AbsoluteUri; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"search_metadata":{"id":"x"}}""") + }); + }); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + await client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "café ñ 日本語" + }); + + // Verify the special characters are present in the URL (encoded) + Assert.NotNull(capturedUrl); + Assert.Contains("q=", capturedUrl!); + // The URL must not contain raw non-ASCII characters + Assert.DoesNotContain("café", capturedUrl!); + Assert.DoesNotContain("日本語", capturedUrl!); + } + + [Fact] + public void Search_Sync_Works() + { + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"search_metadata":{"id":"sync"},"organic_results":[]}""") + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var result = client.Search(new Dictionary + { + ["engine"] = "google", + ["q"] = "sync test" + }); + + Assert.Equal("sync", result.SearchId); + } + + [Fact] + public async Task SearchAsync_ApiKeyNotDuplicatedInParams() + { + string? capturedUrl = null; + var handler = new MockHttpHandler((request, _) => + { + capturedUrl = request.RequestUri?.ToString(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"search_metadata":{"id":"x"}}""") + }); + }); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "main_key" }); + + await client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test", + ["api_key"] = "override_key" // should be ignored + }); + + // api_key from constructor takes precedence, user-passed api_key is skipped + var apiKeyCount = capturedUrl!.Split("api_key=").Length - 1; + Assert.Equal(1, apiKeyCount); + Assert.Contains("api_key=main_key", capturedUrl); + } + + [Fact] + public async Task SearchAsync_CustomBaseUrl() + { + string? capturedUrl = null; + var handler = new MockHttpHandler((request, _) => + { + capturedUrl = request.RequestUri?.ToString(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"search_metadata":{"id":"x"}}""") + }); + }); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key", BaseUrl = "https://custom.api.com" }); + + await client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + }); + + Assert.StartsWith("https://custom.api.com/search?", capturedUrl!); + } + + [Fact] + public async Task NextPageAsync_RejectsCrossOriginUrl() + { + var json = """ + { + "search_metadata":{"id":"abc"}, + "organic_results":[], + "serpapi_pagination":{"next":"https://attacker.com/steal?q=test"} + } + """; + + using var client = new SerpApiClient(new HttpClient(new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"search_metadata":{"id":"x"}}""") + }))), + new SerpApiClientOptions { ApiKey = "key" }); + + var firstPage = new SerpApiResponse(json); + var ex = await Assert.ThrowsAsync(() => client.NextPageAsync(firstPage)); + Assert.Contains("does not match", ex.Message); + } + + [Fact] + public async Task AccountAsync_ThrowsOnApiError() + { + var json = """{"error": "Invalid API key. Your API key should be here: https://serpapi.com/manage-api-key"}"""; + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json) + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "bad_key" }); + + await Assert.ThrowsAsync(() => client.AccountAsync()); + } + + [Fact] + public async Task SearchArchiveAsync_EscapesPathSegment() + { + string? capturedUrl = null; + var handler = new MockHttpHandler((request, _) => + { + capturedUrl = request.RequestUri?.ToString(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"search_metadata":{"id":"x"}}""") + }); + }); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + await client.SearchArchiveAsync("id/with/slashes"); + Assert.DoesNotContain("/id/with/slashes", capturedUrl!); + Assert.Contains("id%2Fwith%2Fslashes", capturedUrl!); + } + + [Fact] + public void SearchPagesAsync_ThrowsOnInvalidMaxPages() + { + using var client = new SerpApiClient("key"); + + Assert.Throws(() => + client.SearchPagesAsync( + new Dictionary { ["engine"] = "google", ["q"] = "test" }, + maxPages: 0)); + } + + [Fact] + public async Task SearchAsync_ThrowsOnNullParameters() + { + using var client = new SerpApiClient("key"); + + var exception = await Assert.ThrowsAsync( + () => client.SearchAsync(null!)); + + Assert.Equal("parameters", exception.ParamName); + } + + [Fact] + public async Task HtmlAsync_ThrowsOnNullParameters() + { + using var client = new SerpApiClient("key"); + + var exception = await Assert.ThrowsAsync( + () => client.HtmlAsync(null!)); + + Assert.Equal("parameters", exception.ParamName); + } + + [Fact] + public void SearchPagesAsync_ThrowsOnNullParameters() + { + using var client = new SerpApiClient("key"); + + var exception = Assert.Throws( + () => client.SearchPagesAsync(null!)); + + Assert.Equal("parameters", exception.ParamName); + } + + [Fact] + public void Constructor_HttpClient_CopiesOptions() + { + var options = new SerpApiClientOptions + { + ApiKey = "original_key", + BaseUrl = "https://serpapi.com", + Timeout = TimeSpan.FromSeconds(30) + }; + + using var client = new SerpApiClient(new HttpClient(), options); + + // Mutating the original options after construction should not affect the client + options.ApiKey = "mutated_key"; + options.BaseUrl = "https://evil.com"; + + // Client should still use original values (verified indirectly via construction succeeding) + Assert.NotNull(client); + } + + // --- Cancellation --- + + [Fact] + public async Task SearchAsync_RespectsCancellationToken() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + var handler = new MockHttpHandler((_, ct) => + { + ct.ThrowIfCancellationRequested(); + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"search_metadata":{"id":"x"}}""") + }); + }); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + await Assert.ThrowsAnyAsync(() => + client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + }, cts.Token)); + } + + [Fact] + public async Task SearchPagesAsync_RespectsCancellation() + { + var callCount = 0; + var handler = new MockHttpHandler((_, _) => + { + callCount++; + var json = """{"search_metadata":{"id":"x"},"organic_results":[],"serpapi_pagination":{"next":"https://serpapi.com/search?start=10"}}"""; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json) + }); + }); + + using var cts = new CancellationTokenSource(); + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var pages = new List(); + await Assert.ThrowsAnyAsync(async () => + { + await foreach (var page in client.SearchPagesAsync( + new Dictionary { ["engine"] = "google", ["q"] = "test" }, + maxPages: 10, + cancellationToken: cts.Token)) + { + pages.Add(page); + if (pages.Count == 2) + cts.Cancel(); + } + }); + + Assert.True(pages.Count >= 2); + } + + // --- SearchPagesAsync iteration --- + + [Fact] + public async Task SearchPagesAsync_StopsWhenNoPagination() + { + var callCount = 0; + var handler = new MockHttpHandler((_, _) => + { + callCount++; + var hasNext = callCount < 3; + var json = hasNext + ? """{"search_metadata":{"id":"x"},"organic_results":[],"serpapi_pagination":{"next":"https://serpapi.com/search?start=10"}}""" + : """{"search_metadata":{"id":"x"},"organic_results":[]}"""; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json) + }); + }); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var pages = new List(); + await foreach (var page in client.SearchPagesAsync( + new Dictionary { ["engine"] = "google", ["q"] = "test" }, + maxPages: 10)) + { + pages.Add(page); + } + + Assert.Equal(3, pages.Count); + } + + [Fact] + public async Task SearchPagesAsync_StopsAtMaxPages() + { + var handler = new MockHttpHandler((_, _) => + { + var json = """{"search_metadata":{"id":"x"},"organic_results":[],"serpapi_pagination":{"next":"https://serpapi.com/search?start=10"}}"""; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json) + }); + }); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var pages = new List(); + await foreach (var page in client.SearchPagesAsync( + new Dictionary { ["engine"] = "google", ["q"] = "test" }, + maxPages: 3)) + { + pages.Add(page); + } + + Assert.Equal(3, pages.Count); + } + + // --- Sync wrappers --- + + [Fact] + public void Html_SyncWorks() + { + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("results") + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var result = client.Html(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + }); + + Assert.Contains("", result); + } + + [Fact] + public void SearchArchive_SyncWorks() + { + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"search_metadata":{"id":"archived"}}""") + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var result = client.SearchArchive("abc123"); + Assert.Equal("archived", result.SearchId); + } + + [Fact] + public void Account_SyncWorks() + { + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"account_id":"123","api_key":"key"}""") + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var result = client.Account(); + Assert.Equal("123", result["account_id"]!.Value.GetString()); + } + + [Fact] + public void Location_SyncWorks() + { + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""[{"id":"1","name":"Austin, TX"}]""") + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var result = client.Location("Austin", limit: 3); + Assert.Equal(JsonValueKind.Array, result.ValueKind); + Assert.Equal(1, result.GetArrayLength()); + } + + // --- HttpRequestException wrapping --- + + [Fact] + public async Task SearchAsync_WrapsHttpRequestException() + { + var handler = new MockHttpHandler((_, _) => + throw new HttpRequestException("DNS resolution failed")); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var ex = await Assert.ThrowsAsync(() => + client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + })); + + Assert.Contains("DNS resolution failed", ex.Message); + Assert.IsType(ex.InnerException); + } + + // --- Non-JSON error response --- + + [Fact] + public async Task SearchAsync_HandlesNonJsonErrorBody() + { + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadGateway) + { + Content = new StringContent("502 Bad Gateway") + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var ex = await Assert.ThrowsAsync(() => + client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + })); + + Assert.Equal(502, ex.StatusCode); + Assert.Contains("502 Bad Gateway", ex.Message); + } + + [Fact] + public async Task SearchAsync_WrapsMalformedJsonResponse() + { + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("not json") + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var ex = await Assert.ThrowsAsync(() => + client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + })); + + Assert.Contains("Failed to parse response", ex.Message); + Assert.IsAssignableFrom(ex.InnerException); + } + + [Fact] + public async Task AccountAsync_WrapsMalformedJsonResponse() + { + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("not json") + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var ex = await Assert.ThrowsAsync(() => client.AccountAsync()); + + Assert.Contains("Failed to parse response", ex.Message); + Assert.IsAssignableFrom(ex.InnerException); + } + + // --- Non-API-key error from response body --- + + [Fact] + public async Task SearchAsync_ThrowsGenericExceptionOnNonKeyError() + { + var json = """{"error": "Google hasn't returned any results for this query."}"""; + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json) + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var ex = await Assert.ThrowsAsync(() => + client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "xyznonexistent123" + })); + + Assert.IsNotType(ex); + Assert.Contains("Google hasn't returned", ex.Message); + } + + [Fact] + public async Task SearchAsync_ThrowsGenericExceptionOnQuotaError() + { + const string message = "You have run out of searches. Upgrade your API key plan."; + var handler = new MockHttpHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent($"{{\"error\":\"{message}\"}}") + })); + + using var client = new SerpApiClient(new HttpClient(handler), + new SerpApiClientOptions { ApiKey = "key" }); + + var exception = await Assert.ThrowsAsync(() => + client.SearchAsync(new Dictionary + { + ["engine"] = "google", + ["q"] = "test" + })); + + Assert.IsNotType(exception); + Assert.Equal(message, exception.Message); + } + + // --- Dispose safety --- + + [Fact] + public void Dispose_WithOwnedClient_DisposesHttpClient() + { + var client = new SerpApiClient("key"); + var ex = Record.Exception(() => client.Dispose()); + Assert.Null(ex); + } + + [Fact] + public void Dispose_WithExternalClient_DoesNotDisposeHttpClient() + { + var httpClient = new HttpClient(); + var client = new SerpApiClient(httpClient, new SerpApiClientOptions { ApiKey = "key" }); + client.Dispose(); + + // httpClient should still be usable (not disposed) + var ex = Record.Exception(() => _ = httpClient.Timeout); + Assert.Null(ex); + httpClient.Dispose(); + } +} diff --git a/test/SerpApiExceptionTests.cs b/test/SerpApiExceptionTests.cs new file mode 100644 index 0000000..d706ed3 --- /dev/null +++ b/test/SerpApiExceptionTests.cs @@ -0,0 +1,36 @@ +namespace SerpApi.Tests; + +public class SerpApiExceptionTests +{ + [Fact] + public void SerpApiException_HasMessage() + { + var ex = new SerpApiException("something went wrong"); + Assert.Equal("something went wrong", ex.Message); + } + + [Fact] + public void SerpApiHttpException_HasStatusCode() + { + var ex = new SerpApiHttpException(429, "Rate limited"); + Assert.Equal(429, ex.StatusCode); + Assert.Equal("Rate limited", ex.Message); + Assert.IsAssignableFrom(ex); + } + + [Fact] + public void SerpApiKeyException_IsAssignableFromBase() + { + var ex = new SerpApiKeyException("Invalid key"); + Assert.IsAssignableFrom(ex); + } + + [Fact] + public void SerpApiTimeoutException_HasInnerException() + { + var inner = new TaskCanceledException(); + var ex = new SerpApiTimeoutException("timed out", inner); + Assert.Equal(inner, ex.InnerException); + Assert.IsAssignableFrom(ex); + } +} diff --git a/test/SerpApiResponseTests.cs b/test/SerpApiResponseTests.cs new file mode 100644 index 0000000..c9876d0 --- /dev/null +++ b/test/SerpApiResponseTests.cs @@ -0,0 +1,133 @@ +using System.Text.Json; + +namespace SerpApi.Tests; + +public class SerpApiResponseTests +{ + [Fact] + public void Indexer_ReturnsProperty() + { + var response = new SerpApiResponse("""{"organic_results":[{"title":"Hello"}]}"""); + Assert.NotNull(response["organic_results"]); + Assert.Equal(JsonValueKind.Array, response["organic_results"]!.Value.ValueKind); + } + + [Fact] + public void Indexer_ReturnsNullForMissingProperty() + { + var response = new SerpApiResponse("""{"search_metadata":{"id":"x"}}"""); + Assert.Null(response["nonexistent"]); + } + + [Fact] + public void SearchMetadata_Works() + { + var response = new SerpApiResponse("""{"search_metadata":{"id":"abc","status":"Success"}}"""); + Assert.NotNull(response.SearchMetadata); + Assert.Equal("abc", response.SearchId); + } + + [Fact] + public void OrganicResults_ParsesArray() + { + var json = """ + { + "search_metadata":{"id":"x"}, + "organic_results":[ + {"position":1,"title":"First"}, + {"position":2,"title":"Second"} + ] + } + """; + var response = new SerpApiResponse(json); + Assert.Equal(2, response.OrganicResults!.Value.GetArrayLength()); + } + + [Fact] + public void NextPageUrl_ReturnsPaginationUrl() + { + var json = """ + { + "search_metadata":{"id":"x"}, + "serpapi_pagination":{"next":"https://serpapi.com/search?start=10"} + } + """; + var response = new SerpApiResponse(json); + Assert.Equal("https://serpapi.com/search?start=10", response.NextPageUrl); + } + + [Fact] + public void NextPageUrl_ReturnsNullWhenNoPagination() + { + var response = new SerpApiResponse("""{"search_metadata":{"id":"x"}}"""); + Assert.Null(response.NextPageUrl); + } + + [Fact] + public void RawJson_ReturnsOriginal() + { + var json = """{"key":"value"}"""; + var response = new SerpApiResponse(json); + Assert.Equal(json, response.RawJson); + } + + [Fact] + public void GetProperty_DeserializesToType() + { + var json = """{"search_metadata":{"id":"x","status":"Success"},"organic_results":[{"title":"A"}]}"""; + var response = new SerpApiResponse(json); + var results = response.GetProperty>>("organic_results"); + Assert.NotNull(results); + Assert.Single(results!); + Assert.Equal("A", results[0]["title"]); + } + + [Fact] + public void As_DeserializesEntireResponse() + { + var json = """{"search_metadata":{"id":"x"},"organic_results":[]}"""; + var response = new SerpApiResponse(json); + var dict = response.As>(); + Assert.NotNull(dict); + Assert.True(dict!.ContainsKey("search_metadata")); + } + + [Fact] + public void ToString_ReturnsJson() + { + var json = """{"a":"b"}"""; + var response = new SerpApiResponse(json); + Assert.Equal(json, response.ToString()); + } + + [Fact] + public void SearchId_NullWhenNoMetadata() + { + var response = new SerpApiResponse("""{"organic_results":[]}"""); + Assert.Null(response.SearchId); + } + + [Fact] + public void SearchMetadata_NullWhenMissing() + { + var response = new SerpApiResponse("""{"organic_results":[]}"""); + Assert.Null(response.SearchMetadata); + } + + [Fact] + public void GetProperty_ReturnsDefaultForMissingKey() + { + var response = new SerpApiResponse("""{"search_metadata":{"id":"x"}}"""); + var result = response.GetProperty>("nonexistent"); + Assert.Null(result); + } + + [Fact] + public void DisposeMultipleTimes_DoesNotThrow() + { + var response = new SerpApiResponse("""{"a":"b"}"""); + response.Dispose(); + var ex = Record.Exception(() => response.Dispose()); + Assert.Null(ex); + } +} diff --git a/test/account_test.cs b/test/account_test.cs deleted file mode 100644 index 84d3667..0000000 --- a/test/account_test.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class AccountTest - { - private SerpApi client; - private string apiKey; - - public AccountTest() - { - apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - this.client = new SerpApi(auth); - } - - public void TestGetAccount() - { - // Skip test on travis ci - if (apiKey == null || apiKey == "demo") - { - return; - } - JObject account = client.account(); - Dictionary dict = account.ToObject>(); - Assert.IsNotNull(dict["account_id"]); - Assert.IsNotNull(dict["plan_id"]); - Assert.AreEqual(dict["apiKey"], apiKey); - } - } -} \ No newline at end of file diff --git a/test/archive_search_test.cs b/test/archive_search_test.cs deleted file mode 100644 index ed552ac..0000000 --- a/test/archive_search_test.cs +++ /dev/null @@ -1,62 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class ArchiveSearchTest - { - private SerpApi client; - private Hashtable ht; - private string apiKey; - - public ArchiveSearchTest() - { - apiKey = Environment.GetEnvironmentVariable("API_KEY"); - - // Localized client for Coffee shop in Austin Texas - ht = new Hashtable(); - ht.Add("engine", "google"); - ht.Add("api_key", apiKey); - } - - [TestMethod] - public void TestArchiveSearch() - { - // Skip test on travis ci - if (apiKey == null || apiKey == "demo") - { - return; - } - - client = new SerpApi(ht); - - Hashtable searchParameter = new Hashtable(); - searchParameter.Add("location", "Austin, Texas, United States"); - searchParameter.Add("q", "Coffee"); - searchParameter.Add("hl", "en"); - searchParameter.Add("google_domain", "google.com"); - - JObject data = client.search(searchParameter); - string id = (string)((JObject)data["search_metadata"])["id"]; - JObject archivedSearch = client.searchArchive(id); - int expected = GetSize((JArray)data["organic_results"]); - int actual = GetSize((JArray)archivedSearch["organic_results"]); - Assert.IsTrue(expected == actual); - } - - private int GetSize(JArray array) - { - int size = 0; - foreach (JObject e in array) - { - size++; - } - return size; - } - } -} \ No newline at end of file diff --git a/test/example_search_apple_app_store_test.cs b/test/example_search_apple_app_store_test.cs deleted file mode 100644 index ee120a5..0000000 --- a/test/example_search_apple_app_store_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for apple_app_store - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class AppleAppStoreTest - { - - public AppleAppStoreTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on apple_app_store - Hashtable query = new Hashtable(); - query.Add("engine", "apple_app_store"); - query.Add("term", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray organic_results = (JArray)results["organic_results"]; - Assert.IsNotNull(organic_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_baidu_test.cs b/test/example_search_baidu_test.cs deleted file mode 100644 index 141c337..0000000 --- a/test/example_search_baidu_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for baidu - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class BaiduTest - { - - public BaiduTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on baidu - Hashtable query = new Hashtable(); - query.Add("engine", "baidu"); - query.Add("q", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray organic_results = (JArray)results["organic_results"]; - Assert.IsNotNull(organic_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_bing_test.cs b/test/example_search_bing_test.cs deleted file mode 100644 index 0bdd87c..0000000 --- a/test/example_search_bing_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for bing - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class BingTest - { - - public BingTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on bing - Hashtable query = new Hashtable(); - query.Add("engine", "bing"); - query.Add("q", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray organic_results = (JArray)results["organic_results"]; - Assert.IsNotNull(organic_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_duckduckgo_test.cs b/test/example_search_duckduckgo_test.cs deleted file mode 100644 index 2fbb68b..0000000 --- a/test/example_search_duckduckgo_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for duckduckgo - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class DuckduckgoTest - { - - public DuckduckgoTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on duckduckgo - Hashtable query = new Hashtable(); - query.Add("engine", "duckduckgo"); - query.Add("q", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray organic_results = (JArray)results["organic_results"]; - Assert.IsNotNull(organic_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_ebay_test.cs b/test/example_search_ebay_test.cs deleted file mode 100644 index a4a1c0c..0000000 --- a/test/example_search_ebay_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for ebay - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class EbayTest - { - - public EbayTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on ebay - Hashtable query = new Hashtable(); - query.Add("engine", "ebay"); - query.Add("_nkw", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray organic_results = (JArray)results["organic_results"]; - Assert.IsNotNull(organic_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_google_autocomplete_test.cs b/test/example_search_google_autocomplete_test.cs deleted file mode 100644 index 423f359..0000000 --- a/test/example_search_google_autocomplete_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for google_autocomplete - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class GoogleAutocompleteTest - { - - public GoogleAutocompleteTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on google_autocomplete - Hashtable query = new Hashtable(); - query.Add("engine", "google_autocomplete"); - query.Add("q", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray suggestions = (JArray)results["suggestions"]; - Assert.IsNotNull(suggestions); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_google_events_test.cs b/test/example_search_google_events_test.cs deleted file mode 100644 index cf8f372..0000000 --- a/test/example_search_google_events_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for google_events - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class GoogleEventsTest - { - - public GoogleEventsTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on google_events - Hashtable query = new Hashtable(); - query.Add("engine", "google_events"); - query.Add("q", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray events_results = (JArray)results["events_results"]; - Assert.IsNotNull(events_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_google_images_test.cs b/test/example_search_google_images_test.cs deleted file mode 100644 index 2bf9851..0000000 --- a/test/example_search_google_images_test.cs +++ /dev/null @@ -1,48 +0,0 @@ -// exampel for google_images - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class GoogleImagesTest - { - - public GoogleImagesTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on google_images - Hashtable query = new Hashtable(); - query.Add("engine", "google_images"); - query.Add("tbm", "isch"); - query.Add("q", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray images_results = (JArray)results["images_results"]; - Assert.IsNotNull(images_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_google_jobs_test.cs b/test/example_search_google_jobs_test.cs deleted file mode 100644 index e5999c7..0000000 --- a/test/example_search_google_jobs_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for google_jobs - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class GoogleJobsTest - { - - public GoogleJobsTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on google_jobs - Hashtable query = new Hashtable(); - query.Add("engine", "google_jobs"); - query.Add("q", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray jobs_results = (JArray)results["jobs_results"]; - Assert.IsNotNull(jobs_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_google_local_services_test.cs b/test/example_search_google_local_services_test.cs deleted file mode 100644 index 4a2389f..0000000 --- a/test/example_search_google_local_services_test.cs +++ /dev/null @@ -1,48 +0,0 @@ -// exampel for google_local_services - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class GoogleLocalServicesTest - { - - public GoogleLocalServicesTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on google_local_services - Hashtable query = new Hashtable(); - query.Add("engine", "google_local_services"); - query.Add("q", "electrician"); - query.Add("data_cid", "6745062158417646970"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray local_ads = (JArray)results["local_ads"]; - Assert.IsNotNull(local_ads); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_google_maps_test.cs b/test/example_search_google_maps_test.cs deleted file mode 100644 index c8cc930..0000000 --- a/test/example_search_google_maps_test.cs +++ /dev/null @@ -1,49 +0,0 @@ -// exampel for google_maps - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class GoogleMapsTest - { - - public GoogleMapsTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on google_maps - Hashtable query = new Hashtable(); - query.Add("engine", "google_maps"); - query.Add("q", "pizza"); - query.Add("ll", "@40.7455096,-74.0083012,15.1z"); - query.Add("type", "search"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray local_results = (JArray)results["local_results"]; - Assert.IsNotNull(local_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_google_play_test.cs b/test/example_search_google_play_test.cs deleted file mode 100644 index d6064b5..0000000 --- a/test/example_search_google_play_test.cs +++ /dev/null @@ -1,48 +0,0 @@ -// exampel for google_play - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class GooglePlayTest - { - - public GooglePlayTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on google_play - Hashtable query = new Hashtable(); - query.Add("engine", "google_play"); - query.Add("q", "kite"); - query.Add("store", "apps"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray organic_results = (JArray)results["organic_results"]; - Assert.IsNotNull(organic_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_google_product_test.cs b/test/example_search_google_product_test.cs deleted file mode 100644 index ff2f84e..0000000 --- a/test/example_search_google_product_test.cs +++ /dev/null @@ -1,49 +0,0 @@ -// exampel for google_product - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class GoogleProductTest - { - - public GoogleProductTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on google_product - Hashtable query = new Hashtable(); - query.Add("engine", "google_product"); - query.Add("q", "coffee"); - query.Add("product_id", "4887235756540435899"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - //Console.WriteLine(results); - JObject product_results = (JObject)results["product_results"]; - Assert.IsNotNull(product_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_google_reverse_image_test.cs b/test/example_search_google_reverse_image_test.cs deleted file mode 100644 index ff1e600..0000000 --- a/test/example_search_google_reverse_image_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for google_reverse_image - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class GoogleReverseImageTest - { - - public GoogleReverseImageTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on google_reverse_image - Hashtable query = new Hashtable(); - query.Add("engine", "google_reverse_image"); - query.Add("image_url", "https://i.imgur.com/5bGzZi7.jpg"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray image_sizes = (JArray)results["image_sizes"]; - Assert.IsNotNull(image_sizes); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_google_scholar_test.cs b/test/example_search_google_scholar_test.cs deleted file mode 100644 index 75acb38..0000000 --- a/test/example_search_google_scholar_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for google_scholar - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class GoogleScholarTest - { - - public GoogleScholarTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on google_scholar - Hashtable query = new Hashtable(); - query.Add("engine", "google_scholar"); - query.Add("q", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray organic_results = (JArray)results["organic_results"]; - Assert.IsNotNull(organic_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_google_test.cs b/test/example_search_google_test.cs deleted file mode 100644 index eabe08b..0000000 --- a/test/example_search_google_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for google - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class GoogleTest - { - - public GoogleTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on google - Hashtable query = new Hashtable(); - query.Add("engine", "google"); - query.Add("q", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray organic_results = (JArray)results["organic_results"]; - Assert.IsNotNull(organic_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_home_depot_test.cs b/test/example_search_home_depot_test.cs deleted file mode 100644 index f9e8ce2..0000000 --- a/test/example_search_home_depot_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for home_depot - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class HomeDepotTest - { - - public HomeDepotTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on home_depot - Hashtable query = new Hashtable(); - query.Add("engine", "home_depot"); - query.Add("q", "table"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray products = (JArray)results["products"]; - Assert.IsNotNull(products); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_naver_test.cs b/test/example_search_naver_test.cs deleted file mode 100644 index a12640c..0000000 --- a/test/example_search_naver_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for naver - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class NaverTest - { - - public NaverTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on naver - Hashtable query = new Hashtable(); - query.Add("engine", "naver"); - query.Add("query", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray ads_results = (JArray)results["ads_results"]; - Assert.IsNotNull(ads_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_walmart_test.cs b/test/example_search_walmart_test.cs deleted file mode 100644 index 0e3ba89..0000000 --- a/test/example_search_walmart_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for walmart - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class WalmartTest - { - - public WalmartTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on walmart - Hashtable query = new Hashtable(); - query.Add("engine", "walmart"); - query.Add("query", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray organic_results = (JArray)results["organic_results"]; - Assert.IsNotNull(organic_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_yahoo_test.cs b/test/example_search_yahoo_test.cs deleted file mode 100644 index 1875fbd..0000000 --- a/test/example_search_yahoo_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for yahoo - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class YahooTest - { - - public YahooTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on yahoo - Hashtable query = new Hashtable(); - query.Add("engine", "yahoo"); - query.Add("p", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray organic_results = (JArray)results["organic_results"]; - Assert.IsNotNull(organic_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/example_search_youtube_test.cs b/test/example_search_youtube_test.cs deleted file mode 100644 index 6581784..0000000 --- a/test/example_search_youtube_test.cs +++ /dev/null @@ -1,47 +0,0 @@ -// exampel for youtube - -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class YoutubeTest - { - - public YoutubeTest() - { - } - - [TestMethod] - public void TestSearch() - { - string apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Assert.IsNotNull(apiKey); - - // Setup client - Hashtable auth = new Hashtable(); - auth.Add("api_key", apiKey); - - SerpApi client = new SerpApi(auth); - - // Search on youtube - Hashtable query = new Hashtable(); - query.Add("engine", "youtube"); - query.Add("search_query", "coffee"); - - JObject results = client.search(query); - Assert.IsNotNull(results); - - JArray video_results = (JArray)results["video_results"]; - Assert.IsNotNull(video_results); - - // release socket connection - client.Close(); - } - } -} \ No newline at end of file diff --git a/test/google_search_test.cs b/test/google_search_test.cs deleted file mode 100644 index 4814b0f..0000000 --- a/test/google_search_test.cs +++ /dev/null @@ -1,80 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class GoogleSearchTest - { - private SerpApi client; - private Hashtable ht; - private string apiKey; - - public GoogleSearchTest() - { - apiKey = Environment.GetEnvironmentVariable("API_KEY"); - - // Localized client for Coffee shop in Austin Texas - ht = new Hashtable(); - ht.Add("engine", "google"); - ht.Add("api_key", apiKey); - } - - [TestMethod] - public void TestSearch() - { - client = new SerpApi(ht); - - Hashtable searchParameter = new Hashtable(); - searchParameter.Add("location", "Austin, Texas, United States"); - searchParameter.Add("q", "Coffee"); - searchParameter.Add("hl", "en"); - searchParameter.Add("google_domain", "google.com"); - - JObject data = client.search(searchParameter); - JArray coffeeShops = (JArray)data["local_results"]["places"]; - int counter = 0; - foreach (JObject coffeeShop in coffeeShops) - { - Assert.IsNotNull(coffeeShop["title"]); - counter++; - } - Assert.IsTrue(counter >= 1); - - coffeeShops = (JArray)data["organic_results"]; - Assert.IsNotNull(coffeeShops); - foreach (JObject coffeeShop in coffeeShops) - { - Console.WriteLine("Found: " + coffeeShop["title"]); - Assert.IsNotNull(coffeeShop["title"]); - } - - // Release socket connection - client.Close(); - } - - - [TestMethod] - public void TestHtml() - { - client = new SerpApi(ht); - - Hashtable searchParameter = new Hashtable(); - searchParameter.Add("location", "Austin, Texas, United States"); - searchParameter.Add("q", "Coffee"); - searchParameter.Add("hl", "en"); - searchParameter.Add("google_domain", "google.com"); - string htmlContent = client.html(searchParameter); - Assert.IsNotNull(htmlContent); - //Console.WriteLine(htmlContent); - Assert.IsTrue(htmlContent.Contains("")); - // Release socket connection - client.Close(); - } - } - -} \ No newline at end of file diff --git a/test/location_test.cs b/test/location_test.cs deleted file mode 100644 index 2e7fb50..0000000 --- a/test/location_test.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class LocationTest - { - private SerpApi client; - private string apiKey; - - public LocationTest() - { - apiKey = Environment.GetEnvironmentVariable("API_KEY"); - } - - [TestMethod] - public void TestLocation() - { - client = new SerpApi(); - Hashtable locationParameter = new Hashtable(); - locationParameter.Add("q", "Austin,TX"); - locationParameter.Add("limit", "5"); - JArray locations = client.location(locationParameter); - int counter = 0; - foreach (JObject location in locations) - { - counter++; - Assert.IsNotNull(location); - Assert.IsNotNull(location.GetValue("id")); - Assert.IsNotNull(location.GetValue("name")); - Assert.IsNotNull(location.GetValue("google_id")); - Assert.IsNotNull(location.GetValue("gps")); - // Console.WriteLine(location); - } - - Assert.AreEqual(1, counter); - } - } -} \ No newline at end of file diff --git a/test/serpapi_search_test.cs b/test/serpapi_search_test.cs deleted file mode 100644 index 6f04cfb..0000000 --- a/test/serpapi_search_test.cs +++ /dev/null @@ -1,94 +0,0 @@ -using Microsoft.VisualStudio.TestTools.UnitTesting; -using SerpApi; -using System; -using Newtonsoft.Json.Linq; -using System.Collections; -using System.Collections.Generic; - -namespace SerpApi.Test -{ - [TestClass] - public class SerpApiClientTest - { - // [TestMethod] - // public void TestSearpApiSearchGetJson() - // { - // String apiKey = Environment.GetEnvironmentVariable("API_KEY"); - // Hashtable ht = new Hashtable(); - // ht.Add("q", "Coffee"); - // ht.Add("location", "Austin, Texas, United States"); - // ht.Add("hl", "en"); - // ht.Add("tbs", "qdr:d"); - // ht.Add("num", "10"); - // ht.Add("google_domain", "google.com"); - // ht.Add("engine", "google"); - - // SerpApiClient client = new SerpApiClient(ht, apiKey); - // JObject data = client.GetJson(); - // JArray coffeeShops = (JArray)data["organic_results"]; - // Assert.IsNotNull(coffeeShops); - // Assert.IsTrue(coffeeShops.Count > 5); - // // Release socket connection - // client.Close(); - // } - - [TestMethod] - public void TestSpecialCharactersEncoding() - { - // special query - string q = "¡ ™£¢∞§¶•ªº–≠œ∑´®†¥¨ˆøπ“‘«åß∂ƒ©åß∂ƒ©˚¬…æ≈ç√∫˜µ≤≥÷" + "`⁄€‹›fifl‡°·‚—±Œ„´‰ˇÁ¨Ø∏”’/* ÍνÓÔÒÚÆ¸˛Ç◊ı˜Â¯˘¿\\n*/" + "ĀāĒēĪīŌōŪū"; - String apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Hashtable ht = new Hashtable(); - ht.Add("q", "Coffee"); - ht.Add("location", "Austin, Texas, United States"); - ht.Add("hl", "en"); - ht.Add("tbs", "qdr:d"); - ht.Add("num", "10"); - ht.Add("google_domain", "google.com"); - ht.Add("engine", "google"); - ht.Add("api_key", apiKey); - SerpApi client = new SerpApi(ht); - client.defaultParameter.Remove("q"); - client.defaultParameter.Remove("location"); - client.defaultParameter.Remove("hl"); - client.defaultParameter.Remove("tbs"); - client.defaultParameter.Remove("num"); - client.defaultParameter.Remove("google_domain"); - client.defaultParameter.Add("q", q); - - string encoded = client.createUrl("/search", client.defaultParameter, false); - string qEncoded = "q=%c2%a1+%e2%84%a2%c2%a3%c2%a2%e2%88%9e%c2%a7%c2%b6%e2%80%a2%c2%aa%c2%ba%e2%80%93%e2%89%a0%c5%93%e2%88%91%c2%b4%c2%ae%e2%80%a0%c2%a5%c2%a8%cb%86%c3%b8%cf%80%e2%80%9c%e2%80%98%c2%ab%c3%a5%c3%9f%e2%88%82%c6%92%c2%a9%c3%a5%c3%9f%e2%88%82%c6%92%c2%a9%cb%9a%c2%ac%e2%80%a6%c3%a6%e2%89%88%c3%a7%e2%88%9a%e2%88%ab%cb%9c%c2%b5%e2%89%a4%e2%89%a5%c3%b7%60%e2%81%84%e2%82%ac%e2%80%b9%e2%80%ba%ef%ac%81%ef%ac%82%e2%80%a1%c2%b0%c2%b7%e2%80%9a%e2%80%94%c2%b1%c5%92%e2%80%9e%c2%b4%e2%80%b0%cb%87%c3%81%c2%a8%c3%98%e2%88%8f%e2%80%9d%e2%80%99%2f*+%c3%8d%c3%8e%cb%9d%c3%93%c3%94%ef%a3%bf%c3%92%c3%9a%c3%86%c2%b8%cb%9b%c3%87%e2%97%8a%c4%b1%cb%9c%c3%82%c2%af%cb%98%c2%bf%5cn*%2f%c4%80%c4%81%c4%92%c4%93%c4%aa%c4%ab%c5%8c%c5%8d%c5%aa%c5%ab"; - Assert.IsTrue(encoded.Contains(qEncoded)); - } - - [TestMethod] - public void TestSearchModifiedSpecialCharacters() - { - String apiKey = Environment.GetEnvironmentVariable("API_KEY"); - Hashtable ht = new Hashtable(); - ht.Add("q", "(h)wīt ˈkôfē !"); - ht.Add("engine", "google"); - ht.Add("api_key", apiKey); - SerpApi client = new SerpApi(ht); - JObject data = client.search(ht); - dynamic first = ((JArray)data["organic_results"])[0]; - Assert.AreEqual((string)first["position"], "1"); - - // modified request - ht.Remove("q"); - ht.Add("q", "blak ˈkôfē !"); - - // run client 2 - JObject data_ = client.search(ht); - dynamic first_ = ((JArray)data_["organic_results"])[0]; - Assert.AreEqual((string)first_["position"], "1"); - - // check output is different - Assert.AreNotEqual((string)first_["title"], (string)first["title"]); - - // Release socket - client.Close(); - } - } - -} \ No newline at end of file diff --git a/test/test.csproj b/test/test.csproj index 7800f86..b77a687 100644 --- a/test/test.csproj +++ b/test/test.csproj @@ -1,14 +1,24 @@ + net10.0 + enable + enable false true - - - + + + + + + + + + + diff --git a/test/xunit.runner.json b/test/xunit.runner.json new file mode 100644 index 0000000..6b2d9df --- /dev/null +++ b/test/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeTestCollections": true, + "maxParallelThreads": -1 +}