Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
- name: Setup .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: '6.0.x'
dotnet-version: '10.0.x'

- name: Display .NET environment info
run: dotnet --info
Expand Down
2 changes: 1 addition & 1 deletion Infrastructer/Core.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
Expand Down
15 changes: 15 additions & 0 deletions Infrastructer/Steam/Exceptions/SteamResponseParseException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;

namespace Infrastructer.Steam.Exceptions
{
public class SteamResponseParseException : Exception
{
public SteamResponseParseException(string message) : base(message)
{
}

public SteamResponseParseException(string message, Exception innerException) : base(message, innerException)
{
}
}
}
2 changes: 1 addition & 1 deletion Infrastructer/Steam/Services/Abstraction/ISteamService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,6 @@ namespace Infrastructer.Steam.Services.Abstraction
public interface ISteamService
{

List<Listing> GetListings(string game, Culture culture);
Task<List<Listing>> GetListingsAsync(string game, Culture culture, CancellationToken cancellationToken = default);
}
}
28 changes: 20 additions & 8 deletions Infrastructer/Steam/Services/Implementation/SteamService.cs
Original file line number Diff line number Diff line change
@@ -1,28 +1,40 @@
using HtmlAgilityPack;
using Infrastructer.Steam.CustomTypes;
using Infrastructer.Steam.Enumerations;
using Infrastructer.Steam.Exceptions;
using Infrastructer.Steam.Services.Abstraction;
using System.Globalization;
using System.Net;

namespace Infrastructer.Steam.Services.Implementation
{
public class SteamService : ISteamService
{
private readonly HttpClient _httpClient;
private readonly ICultureService _cultureService;

public SteamService(ICultureService cultureService)
public SteamService(HttpClient httpClient, ICultureService cultureService)
{
_cultureService = cultureService;
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_cultureService = cultureService ?? throw new ArgumentNullException(nameof(cultureService));
}

public List<Listing> GetListings(string game, Culture culture)
public async Task<List<Listing>> GetListingsAsync(string game, Culture culture, CancellationToken cancellationToken = default)
{
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
cancellationToken.ThrowIfCancellationRequested();

var cultureInfo = _cultureService.GetCultureValue(culture);
string response = new WebClient().DownloadString($"https://store.steampowered.com/search/suggest?term={game}&f=games&cc={cultureInfo.CultureShortName}&lang={cultureInfo.CultureName}&v=2286217");
var encodedTerm = Uri.EscapeDataString(game ?? string.Empty);
var relativePath = $"search/suggest?term={encodedTerm}&f=games&cc={cultureInfo.CultureShortName}&lang={cultureInfo.CultureName}&v=2286217";

var requestUri = _httpClient.BaseAddress != null
? new Uri(relativePath, UriKind.Relative)
: new Uri($"https://store.steampowered.com/{relativePath}", UriKind.Absolute);

using var response = await _httpClient.GetAsync(requestUri, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();

return ParseResponse(response, cultureInfo.CultureInfo);
var responseContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return ParseResponse(responseContent, cultureInfo.CultureInfo);
}

internal static List<Listing> ParseResponse(string response, CultureInfo cultureInfo)
Expand Down Expand Up @@ -53,7 +65,7 @@ internal static List<Listing> ParseResponse(string response, CultureInfo culture
// If upstream provided match elements but none could be parsed, fail explicitly
if (listingNodes.Any() && results.Count == 0)
{
throw new InvalidOperationException("Detected upstream Steam search markup failure; matched listing elements could not be parsed.");
throw new SteamResponseParseException("Detected upstream Steam search markup failure; matched listing elements could not be parsed.");
}

return results;
Expand Down
229 changes: 229 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
# SteamAPI

A lightweight ASP.NET Core 10 Web API providing structured search suggestions from the Steam store.

> **Project Notice:**<br>
> *For official releases, published binaries, and release notes, see [GitHub Releases](https://github.com/fatihemres/SteamAPI/releases).*<br>
> *SteamAPI is not an official Valve product and is not endorsed by or affiliated with Valve Corporation.*

---

## Overview

**SteamAPI** is an API service built on **.NET 10** (`net10.0`) that translates search queries into regional Steam store suggestion requests, parses the returned HTML markup, and provides structured JSON listings containing game identifiers, titles, store URLs, capsule imagery, pricing, and free-to-play classifications.

### Key Features

- **Regional Localization:** Support for three Steam store cultures (`tr_TR`, `en_US`, `es_ES`) mapping country codes and languages.
- **Asynchronous Architecture:** Non-blocking async/await pipeline using a typed `HttpClient` registered via `IHttpClientFactory` with managed connection lifetimes.
- **Input Length Capping:** Enforces non-empty search terms and limits search strings to a maximum of 200 UTF-16 code units (`string.Length`) to prevent query buffer bloat and excessive upstream URI construction.
- **Structured Error Handling:** Standardized **RFC 9457 Problem Details** for specifically handled validation and upstream failures:
- `400 Bad Request` for invalid request bodies, empty search terms, search terms exceeding 200 code units, or out-of-range culture values.
- `502 Bad Gateway` for upstream Steam non-success HTTP status codes, network errors, unparseable markup (`SteamResponseParseException`), refused redirects, or responses exceeding buffer limits.
- `504 Gateway Timeout` for upstream requests exceeding the configured 30-second timeout.
- **Resource Hardened:**
- Automatic HTTP redirects disabled (`AllowAutoRedirect = false`) on the primary HTTP handler to prevent unexpected cross-host redirect following.
- Upstream response buffering capped at **1 MiB** (`MaxResponseContentBufferSize = 1,048,576 bytes`).
- Request-cancellation propagation via `CancellationToken`.
- **Deterministic Test Suite:** 157 offline tests covering contract verification, culture mapping, price parsing, and failure classifications.

---

## Requirements

- [.NET 10.0 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) (version `10.0.100` or newer)

Verify your installed .NET SDK version:

```shell
dotnet --version
```

---

## Getting Started

### 1. Clone the Repository

Clone the repository and enter the directory:

```shell
git clone https://github.com/fatihemres/SteamAPI.git
cd SteamAPI
```

### 2. HTTPS Certificate Setup (Development)

The application configures HTTPS redirection via `app.UseHttpsRedirection()`. For local development with HTTPS, ensure your local development certificate is generated and trusted:

```shell
dotnet dev-certs https --trust
```

*(On platforms or environments where `--trust` is not supported, generate the certificate with `dotnet dev-certs https` and follow platform-specific trust procedures).*

### 3. Restore and Build

```shell
dotnet restore SteamAPI.sln
dotnet build SteamAPI.sln -c Release
```

### 4. Run the Application

Run using the project's configured `SteamAPI` launch profile:

```shell
dotnet run --project SteamAPI/SteamAPI.csproj --launch-profile SteamAPI
```

When started with the `SteamAPI` profile in the `Development` environment, Kestrel prints the active listening endpoints:
- HTTPS: `https://localhost:7030`
- HTTP: `http://localhost:5271`

Because `UseHttpsRedirection()` is active, HTTP requests to port 5271 are redirected to HTTPS on port 7030. Always check the actual console output from Kestrel to confirm active listening URLs.

Interactive OpenAPI documentation is available locally when running in `Development`:
- Swagger UI: `https://localhost:7030/swagger/index.html`
- OpenAPI Specification: `https://localhost:7030/swagger/v1/swagger.json`

*(Note: Swagger UI is enabled exclusively in the `Development` environment).*

### 5. Quick Query Examples

#### PowerShell (Windows)

```powershell
$body = @{
game = "Portal"
culture = 2
} | ConvertTo-Json

Invoke-RestMethod -Uri "https://localhost:7030/steam" -Method Post -ContentType "application/json" -Body $body
```

#### cURL (Linux / macOS / Bash)

```shell
curl -X POST "https://localhost:7030/steam" \
-H "Content-Type: application/json" \
-d '{"game": "Portal", "culture": 2}'
```

---

## API Quick Start

### `POST /steam`

Queries Steam store search suggestions for matching game titles.

#### Request Body

```json
{
"game": "Portal",
"culture": 2
}
```

| Field | Type | Required | Constraints | Description |
| :--- | :--- | :---: | :--- | :--- |
| `game` | `string` | **Yes** | 1–200 UTF-16 code units, non-whitespace | Search term (e.g. game title, keyword). |
| `culture` | `integer` | **Yes** | Must be `1`, `2`, or `3` | Regional store and language identifier. |

#### Supported Cultures

| Culture Value | Enum Identifier | Region / Language Mapping | Steam Parameters |
| :---: | :--- | :--- | :--- |
| `1` | `tr_TR` | Turkey / Turkish | `cc=tr&lang=turkish` |
| `2` | `en_US` | United States / English | `cc=us&lang=english` |
| `3` | `es_ES` | Spain / Spanish | `cc=es&lang=spanish` |

#### Successful Response (`200 OK`)

Returns a top-level JSON array of `Listing` objects:

```json
[
{
"name": "Portal 2",
"storeLink": "https://store.steampowered.com/app/620/Portal_2",
"imageLink": "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/620/bd23943dcd0280aa2cdc066e2f86d2185cfd4cce/capsule_sm_120.jpg",
"price": 9.99,
"saleType": 0,
"appId": "620"
},
{
"name": "Portal",
"storeLink": "https://store.steampowered.com/app/400/Portal",
"imageLink": "https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/400/capsule_sm_120.jpg",
"price": 9.99,
"saleType": 0,
"appId": "400"
}
]
```

*Note: Pricing and ranking data in examples are illustrative only and reflect dynamic upstream Steam store results.*

#### Sale Types (`saleType`)

| Value | Identifier | Description | `price` Field |
| :---: | :--- | :--- | :--- |
| `0` | `CostsMoney` | Paid title | Floating-point numeric price (e.g. `9.99`). |
| `1` | `FreeToPlay` | Free or Free-to-Play title | `null` |
| `2` | `NotAvailable` | Title without available pricing | `null` |

*Important:* The `price` field represents a raw numeric amount without a currency symbol. The requested culture does not guarantee a specific currency (for example, Steam's Turkish regional store prices titles in USD). Do not attempt automatic currency conversions without an external currency rate source.

For detailed schema constraints and error contracts, see the [API Reference](docs/API.md).

---

## Error Handling

Specifically handled validation and upstream failures return **RFC 9457 Problem Details for HTTP APIs**:

- **`400 Bad Request`:** Request validation failure (e.g. empty game name, game name exceeding 200 code units, unsupported culture integer). Returns a `ValidationProblemDetails` payload specifying field errors.
- **`502 Bad Gateway`:** Upstream Steam non-success HTTP status codes (such as 404, 429, 500, 503), network connectivity failures, unparseable Steam HTML markup (`SteamResponseParseException`), refused redirects, or upstream responses exceeding the 1 MiB buffer limit.
- **`504 Gateway Timeout`:** The upstream Steam service failed to respond within the configured 30-second timeout window.
- **Client Cancellation:** If the caller aborts the request, the cancellation token cancels in-flight operations promptly.

---

## Testing

The project maintains a 100% deterministic, offline automated test suite. Automated tests execute using synthetic fixtures and in-memory test doubles without issuing live network calls to Valve or external services.

Run the test suite across configurations:

```shell
# Debug Configuration
dotnet test SteamAPI.sln -c Debug

# Release Configuration
dotnet test SteamAPI.sln -c Release
```

For test inventory details, see [Release Candidate Notes](docs/releases/v0.2.0-rc-notes.md).

---

## Known Limitations & Deployment Notes

- **Upstream Scraping Dependency:** Steam does not provide an official frozen public contract for search suggestions. The API parses the suggestion HTML markup returned by `store.steampowered.com`.
- **Unrecognized Markup Behavior:** Completely unrecognized HTML containing no expected `match` elements (e.g. an unexpected full-page redesign or bot-challenge returned with HTTP 200) currently produces an empty result array `[]`.
- **Currency Code Omission:** The public Listing contract provides a numeric `price` but does not carry an ISO 4217 currency code.
- **Deployment Protections:** This milestone does not implement application-level rate limiting or production monitoring. The application is intended for deployment behind a managed reverse proxy or API gateway (such as YARP, Nginx, or Cloudflare) configured with appropriate rate limiting, TLS termination, and health probes.
- **Repository Licensing:** The repository currently does not contain a declared open-source LICENSE file; public visibility does not imply open-source licensing.

---

## Documentation and Releases

- Comprehensive API specification: [docs/API.md](docs/API.md)
- Release candidate notes and migration details: [docs/releases/v0.2.0-rc-notes.md](docs/releases/v0.2.0-rc-notes.md)
- Official releases and version tags: [GitHub Releases](https://github.com/fatihemres/SteamAPI/releases)

*Valve, Steam, and the Steam logo are trademarks and/or registered trademarks of Valve Corporation in the U.S. and/or other countries. All other trademarks are the property of their respective owners.*
5 changes: 3 additions & 2 deletions SteamAPI.Tests/ListingParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Text.Json;
using Infrastructer.Steam.CustomTypes;
using Infrastructer.Steam.Enumerations;
using Infrastructer.Steam.Exceptions;
using Infrastructer.Steam.Services.Implementation;
using Xunit;

Expand Down Expand Up @@ -182,13 +183,13 @@ public void ParseResponse_MalformedListingAlongsideValidListing_ShouldFilterMalf
}

[Fact]
public void ParseResponse_AllMatchedListingsUnparseable_ShouldThrowInvalidOperationException()
public void ParseResponse_AllMatchedListingsUnparseable_ShouldThrowSteamResponseParseException()
{
// Upstream structure changed such that match elements exist, but not a single element can be parsed
string html = @"<a class=""match""><div>Changed upstream completely</div></a>
<a class=""match""><div>Another unparseable element</div></a>";

var ex = Assert.Throws<InvalidOperationException>(() =>
var ex = Assert.Throws<SteamResponseParseException>(() =>
SteamService.ParseResponse(html, new CultureInfo("en-US")));

Assert.Contains("Detected upstream Steam search markup failure", ex.Message);
Expand Down
Loading
Loading