diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index f028f04a..b3b090ba 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -17,7 +17,7 @@ "rollForward": false }, "dotnet-stryker": { - "version": "4.8.1", + "version": "4.11.0", "commands": [ "dotnet-stryker" ], diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 51fc71c8..4ed72641 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,5 @@ # TLDR; -# Summary - # Details + +# How Do The Tests Prove The Changes Work? diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml new file mode 100644 index 00000000..9851adb2 --- /dev/null +++ b/.github/workflows/deploy-website.yml @@ -0,0 +1,56 @@ +name: Deploy Website to GitHub Pages + +on: + push: + branches: + - main + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: Website/package-lock.json + + - name: Install dependencies + working-directory: Website + run: npm ci + + - name: Build website + working-directory: Website + run: npm run build + + - name: Setup Pages + uses: actions/configure-pages@v5 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: Website/_site + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index f4fb36eb..762e9e59 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,11 @@ coverage.cobertura.xml final-check/ test-output-final nupkgs/ + +# Website +Website/node_modules/ +Website/_site/ +Website/package-lock.json +Website/playwright-report/ +Website/test-results/ +Website/src/api/ diff --git a/CLAUDE.md b/CLAUDE.md index f58f6b62..9c12062f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,12 @@ RestClient.Net is a functional HTTP client library using Result types for error 5. **ProgressReportingHttpContent** - Custom `HttpContent` supporting upload/download progress callbacks. +### PRs + +- Compare the current branch to main branch. Base title and comments solely on diff. IGNORE commit messages +- Use the template at .github/pull_request_template.md +- Keep the documentation tight. + ### Code Generation **RestClient.Net.OpenApiGenerator** - Generates C# extension methods from OpenAPI 3.x specs: diff --git a/Website/eleventy.config.js b/Website/eleventy.config.js new file mode 100644 index 00000000..76ef3954 --- /dev/null +++ b/Website/eleventy.config.js @@ -0,0 +1,28 @@ +import techdoc from "eleventy-plugin-techdoc"; + +export default function(eleventyConfig) { + eleventyConfig.addPlugin(techdoc, { + site: { + name: "RestClient.Net", + url: "https://restclient.net", + description: "The safest way to make REST calls in C#. Built with functional programming, type safety, and modern .NET patterns.", + }, + features: { + blog: true, + docs: true, + darkMode: true, + i18n: true, + }, + i18n: { + defaultLanguage: 'en', + languages: ['en', 'zh'], + }, + }); + + eleventyConfig.addPassthroughCopy("src/assets"); + + return { + dir: { input: "src", output: "_site" }, + markdownTemplateEngine: "njk", + }; +} diff --git a/Website/package.json b/Website/package.json new file mode 100644 index 00000000..dcb947d6 --- /dev/null +++ b/Website/package.json @@ -0,0 +1,19 @@ +{ + "name": "restclient-net-website", + "version": "1.0.0", + "description": "RestClient.Net documentation website", + "type": "module", + "scripts": { + "generate-api": "node scripts/generate-api-docs.js", + "dev": "lsof -ti:8080 | xargs kill -9 2>/dev/null; npx @11ty/eleventy --serve --port=8080", + "build": "npm run generate-api && npx @11ty/eleventy", + "test": "playwright test" + }, + "dependencies": { + "eleventy-plugin-techdoc": "^0.1.0" + }, + "devDependencies": { + "@11ty/eleventy": "^3.1.2", + "@playwright/test": "^1.40.0" + } +} diff --git a/Website/playwright.config.js b/Website/playwright.config.js new file mode 100644 index 00000000..94f93b70 --- /dev/null +++ b/Website/playwright.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'list', + use: { + baseURL: 'http://localhost:8080', + trace: 'on-first-retry', + }, + webServer: { + command: 'npx @11ty/eleventy --serve --port=8080', + url: 'http://localhost:8080', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, +}); diff --git a/Website/scripts/generate-api-docs.js b/Website/scripts/generate-api-docs.js new file mode 100644 index 00000000..4ee9afa4 --- /dev/null +++ b/Website/scripts/generate-api-docs.js @@ -0,0 +1,1255 @@ +#!/usr/bin/env node + +/** + * Generate API documentation for RestClient.Net from C# source files. + * Extracts XML documentation comments and generates Markdown with proper links. + * + * Source: RestClient.Net repo root (parent of Website folder) + * Output: Website/src/api/ + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const WEBSITE_DIR = path.dirname(__dirname); +const RESTCLIENT_NET_DIR = path.dirname(WEBSITE_DIR); +const API_OUTPUT_DIR = path.join(WEBSITE_DIR, 'src', 'api'); +const API_OUTPUT_DIR_ZH = path.join(WEBSITE_DIR, 'src', 'zh', 'api'); + +// .NET type to Microsoft docs URL mapping +const DOTNET_DOCS = { + 'HttpClient': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclient', + 'IHttpClientFactory': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.ihttpclientfactory', + 'HttpContent': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpcontent', + 'HttpResponseMessage': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpresponsemessage', + 'HttpStatusCode': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.httpstatuscode', + 'HttpMethod': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpmethod', + 'CancellationToken': 'https://learn.microsoft.com/en-us/dotnet/api/system.threading.cancellationtoken', + 'Task': 'https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task-1', + 'Exception': 'https://learn.microsoft.com/en-us/dotnet/api/system.exception', + 'Func': 'https://learn.microsoft.com/en-us/dotnet/api/system.func-2', + 'JsonSerializerOptions': 'https://learn.microsoft.com/en-us/dotnet/api/system.text.json.jsonserializeroptions', + 'FormUrlEncodedContent': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.formurlencodedcontent', + 'MultipartFormDataContent': 'https://learn.microsoft.com/en-us/dotnet/api/system.net.http.multipartformdatacontent', + 'XmlSerializer': 'https://learn.microsoft.com/en-us/dotnet/api/system.xml.serialization.xmlserializer', + 'Stream': 'https://learn.microsoft.com/en-us/dotnet/api/system.io.stream', + 'IReadOnlyDictionary': 'https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.ireadonlydictionary-2', +}; + +// Internal links within the API docs +const INTERNAL_LINKS = { + 'Result': '/api/result-types/#resulttsuccessterror', + 'HttpError': '/api/result-types/#httperrorterror', + 'ResponseError': '/api/result-types/#responseerror-properties', + 'ExceptionError': '/api/result-types/#exceptionerror-properties', + 'Deserialize': '/api/serialization/', + 'Serialize': '/api/serialization/', +}; + +// External documentation links +const EXTERNAL_DOCS = { + 'OpenAPI': 'https://swagger.io/specification/', + 'MCP': 'https://modelcontextprotocol.io/', + 'record': 'https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record', + 'switch expression': 'https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/switch-expression', + 'global using': 'https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/using-directive#global-modifier', + 'Roslyn analyzer': 'https://learn.microsoft.com/en-us/dotnet/csharp/roslyn-sdk/', +}; + +/** + * Parse XML documentation from a C# file + */ +function parseXmlDoc(content) { + const members = []; + + // Match /// blocks followed by method/class declarations + const docPattern = /\/\/\/\s*\s*([\s\S]*?)\/\/\/\s*<\/summary>([\s\S]*?)(?=public|private|protected|internal|\[)/g; + + let match; + while ((match = docPattern.exec(content)) !== null) { + const summaryLines = match[1].split('\n') + .map(line => line.replace(/^\s*\/\/\/\s*/, '').trim()) + .filter(line => line.length > 0) + .join(' '); + + const additionalDoc = match[2]; + + // Extract param tags + const params = []; + const paramPattern = /\/\/\/\s*(.*?)<\/param>/g; + let paramMatch; + while ((paramMatch = paramPattern.exec(additionalDoc)) !== null) { + params.push({ name: paramMatch[1], description: paramMatch[2].trim() }); + } + + // Extract typeparam tags + const typeParams = []; + const typeParamPattern = /\/\/\/\s*(.*?)<\/typeparam>/g; + let typeParamMatch; + while ((typeParamMatch = typeParamPattern.exec(additionalDoc)) !== null) { + typeParams.push({ name: typeParamMatch[1], description: typeParamMatch[2].trim() }); + } + + // Extract returns tag + const returnsMatch = /\/\/\/\s*(.*?)<\/returns>/s.exec(additionalDoc); + const returns = returnsMatch ? returnsMatch[1].trim() : null; + + members.push({ summary: summaryLines, params, typeParams, returns }); + } + + return members; +} + +/** + * Convert a type name to a linked version + */ +function linkType(typeName) { + // Check .NET docs first + for (const [type, url] of Object.entries(DOTNET_DOCS)) { + if (typeName.includes(type)) { + return typeName.replace(type, `[${type}](${url})`); + } + } + + // Check internal links + for (const [type, url] of Object.entries(INTERNAL_LINKS)) { + if (typeName.includes(type)) { + return typeName.replace(type, `[${type}](${url})`); + } + } + + return typeName; +} + +/** + * Process see cref tags to create links + */ +function processSeeCref(text) { + return text.replace(//g, (match, cref) => { + const typeName = cref.split('.').pop(); + const url = DOTNET_DOCS[typeName] || INTERNAL_LINKS[typeName]; + return url ? `[\`${typeName}\`](${url})` : `\`${typeName}\``; + }); +} + +/** + * Generate markdown for a parameter table + */ +function generateParamTable(params, typeParams) { + if (params.length === 0 && typeParams.length === 0) return ''; + + let md = '### Parameters\n\n'; + md += '| Parameter | Type | Description |\n'; + md += '|-----------|------|-------------|\n'; + + for (const tp of typeParams) { + md += `| \`${tp.name}\` | Type parameter | ${processSeeCref(tp.description)} |\n`; + } + + for (const p of params) { + const desc = processSeeCref(p.description); + md += `| \`${p.name}\` | See signature | ${desc} |\n`; + } + + return md + '\n'; +} + +/** + * Generate HttpClient Extensions CLASS SUMMARY page (just a table of methods with links) + */ +function generateHttpClientExtensions() { + return `--- +layout: layouts/api.njk +title: HttpClientExtensions Class +description: Extension methods for HttpClient that return Result types instead of throwing exceptions. +keywords: HttpClientExtensions, HttpClient, REST API, C# HTTP client, extension methods +eleventyNavigation: + key: HttpClient Extensions + parent: API Reference + order: 1 +permalink: /api/httpclient-extensions/ +--- + +Extension methods for [\`HttpClient\`](${DOTNET_DOCS.HttpClient}) that return [\`Result>\`](/api/result/) instead of throwing exceptions. + +## Namespace + +\`RestClient.Net\` + +## Methods + +| Method | Description | +|--------|-------------| +| [GetAsync<TSuccess, TError>](/api/getasync/) | Make a type-safe GET request | +| [PostAsync<TRequest, TSuccess, TError>](/api/postasync/) | Make a type-safe POST request with body | +| [PutAsync<TRequest, TSuccess, TError>](/api/putasync/) | Make a type-safe PUT request for full replacement | +| [DeleteAsync<TSuccess, TError>](/api/deleteasync/) | Make a type-safe DELETE request | +| [PatchAsync<TRequest, TSuccess, TError>](/api/patchasync/) | Make a type-safe PATCH request for partial updates | + +## See Also + +- [Result<TSuccess, TError>](/api/result/) - The discriminated union return type +- [HttpError<TError>](/api/httperror/) - HTTP-specific error wrapper +- [Serialization](/api/serialization/) - Custom serialization and deserialization +`; +} + +/** + * Generate GetAsync METHOD DETAIL page + */ +function generateGetAsync() { + return `--- +layout: layouts/api.njk +title: GetAsync Method +description: Make a type-safe GET request that returns Result instead of throwing exceptions. +keywords: GetAsync, HTTP GET, RestClient.Net, type-safe HTTP +eleventyNavigation: + key: GetAsync + parent: HttpClient Extensions + order: 1 +permalink: /api/getasync/ +--- + +Make a type-safe GET request. + +## Namespace + +\`RestClient.Net\` + +## Containing Type + +[HttpClientExtensions](/api/httpclient-extensions/) + +## Signature + +\`\`\`csharp +public static async Task>> GetAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Func> deserializeSuccess, + Func> deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +\`\`\` + +## Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| \`url\` | \`AbsoluteUrl\` | The request URL (use \`.ToAbsoluteUrl()\` extension) | +| \`deserializeSuccess\` | [\`Func>\`](${DOTNET_DOCS.Func}) | Function to deserialize success response | +| \`deserializeError\` | [\`Func>\`](${DOTNET_DOCS.Func}) | Function to deserialize error response | +| \`headers\` | [\`IReadOnlyDictionary?\`](${DOTNET_DOCS.IReadOnlyDictionary}) | Optional request headers | +| \`cancellationToken\` | [\`CancellationToken\`](${DOTNET_DOCS.CancellationToken}) | Optional cancellation token | + +## Returns + +[\`Task>>\`](${DOTNET_DOCS.Task}) - A discriminated union that is either: +- [\`Ok\`](/api/ok/) - Success with deserialized data +- [\`Error>\`](/api/error/) - Error with [ResponseError](/api/responseerror/) or [ExceptionError](/api/exceptionerror/) + +## Example + +\`\`\`csharp +var result = await httpClient.GetAsync( + url: "https://api.example.com/users/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializeUser, + deserializeError: DeserializeApiError +); + +var output = result switch +{ + OkUser(var user) => $"Found: {user.Name}", + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"API Error {status}: {err.Message}", + ErrorUser(ExceptionErrorUser(var ex)) => $"Exception: {ex.Message}", +}; +\`\`\` + +## See Also + +- [HttpClientExtensions](/api/httpclient-extensions/) - All extension methods +- [Result<TSuccess, TError>](/api/result/) - The return type +- [Serialization](/api/serialization/) - Deserializer examples +`; +} + +/** + * Generate PostAsync METHOD DETAIL page + */ +function generatePostAsync() { + return `--- +layout: layouts/api.njk +title: PostAsync Method +description: Make a type-safe POST request with a request body that returns Result instead of throwing exceptions. +keywords: PostAsync, HTTP POST, RestClient.Net, type-safe HTTP +eleventyNavigation: + key: PostAsync + parent: HttpClient Extensions + order: 2 +permalink: /api/postasync/ +--- + +Make a type-safe POST request with a request body. + +## Namespace + +\`RestClient.Net\` + +## Containing Type + +[HttpClientExtensions](/api/httpclient-extensions/) + +## Signature + +\`\`\`csharp +public static async Task>> PostAsync( + this HttpClient httpClient, + AbsoluteUrl url, + TRequest body, + Func serializeRequest, + Func> deserializeSuccess, + Func> deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +\`\`\` + +## Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| \`url\` | \`AbsoluteUrl\` | The request URL | +| \`body\` | \`TRequest\` | The request body object | +| \`serializeRequest\` | [\`Func\`](${DOTNET_DOCS.Func}) | Function to serialize the request body | +| \`deserializeSuccess\` | [\`Func>\`](${DOTNET_DOCS.Func}) | Function to deserialize success response | +| \`deserializeError\` | [\`Func>\`](${DOTNET_DOCS.Func}) | Function to deserialize error response | +| \`headers\` | [\`IReadOnlyDictionary?\`](${DOTNET_DOCS.IReadOnlyDictionary}) | Optional request headers | +| \`cancellationToken\` | [\`CancellationToken\`](${DOTNET_DOCS.CancellationToken}) | Optional cancellation token | + +## Returns + +[\`Task>>\`](${DOTNET_DOCS.Task}) - A discriminated union that is either: +- [\`Ok\`](/api/ok/) - Success with deserialized data +- [\`Error>\`](/api/error/) - Error with [ResponseError](/api/responseerror/) or [ExceptionError](/api/exceptionerror/) + +## Example + +\`\`\`csharp +var newUser = new CreateUserRequest("John", "john@example.com"); + +var result = await httpClient.PostAsync( + url: "https://api.example.com/users".ToAbsoluteUrl(), + body: newUser, + serializeRequest: SerializeJson, + deserializeSuccess: DeserializeUser, + deserializeError: DeserializeApiError +); +\`\`\` + +## See Also + +- [HttpClientExtensions](/api/httpclient-extensions/) - All extension methods +- [Serialization](/api/serialization/) - Serializer examples +`; +} + +/** + * Generate PutAsync METHOD DETAIL page + */ +function generatePutAsync() { + return `--- +layout: layouts/api.njk +title: PutAsync Method +description: Make a type-safe PUT request for full resource replacement. +keywords: PutAsync, HTTP PUT, RestClient.Net, type-safe HTTP +eleventyNavigation: + key: PutAsync + parent: HttpClient Extensions + order: 3 +permalink: /api/putasync/ +--- + +Make a type-safe PUT request for full resource replacement. + +## Namespace + +\`RestClient.Net\` + +## Containing Type + +[HttpClientExtensions](/api/httpclient-extensions/) + +## Signature + +Same signature as [PostAsync](/api/postasync/). + +\`\`\`csharp +public static async Task>> PutAsync( + this HttpClient httpClient, + AbsoluteUrl url, + TRequest body, + Func serializeRequest, + Func> deserializeSuccess, + Func> deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +\`\`\` + +## Example + +\`\`\`csharp +var updatedUser = new UpdateUserRequest("John Updated", "john.updated@example.com"); + +var result = await httpClient.PutAsync( + url: "https://api.example.com/users/123".ToAbsoluteUrl(), + body: updatedUser, + serializeRequest: SerializeJson, + deserializeSuccess: DeserializeUser, + deserializeError: DeserializeApiError +); +\`\`\` + +## See Also + +- [PostAsync](/api/postasync/) - Same signature, for creating resources +- [PatchAsync](/api/patchasync/) - For partial updates +`; +} + +/** + * Generate DeleteAsync METHOD DETAIL page + */ +function generateDeleteAsync() { + return `--- +layout: layouts/api.njk +title: DeleteAsync Method +description: Make a type-safe DELETE request. +keywords: DeleteAsync, HTTP DELETE, RestClient.Net, type-safe HTTP +eleventyNavigation: + key: DeleteAsync + parent: HttpClient Extensions + order: 4 +permalink: /api/deleteasync/ +--- + +Make a type-safe DELETE request. + +## Namespace + +\`RestClient.Net\` + +## Containing Type + +[HttpClientExtensions](/api/httpclient-extensions/) + +## Signature + +Same signature as [GetAsync](/api/getasync/). + +\`\`\`csharp +public static async Task>> DeleteAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Func> deserializeSuccess, + Func> deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +\`\`\` + +## Example + +\`\`\`csharp +var result = await httpClient.DeleteAsync( + url: "https://api.example.com/users/123".ToAbsoluteUrl(), + deserializeSuccess: async (r, ct) => true, + deserializeError: DeserializeApiError +); + +var output = result switch +{ + Ok(true) => "User deleted", + Error(ResponseError(var err, var status, _)) => $"Error {status}: {err.Message}", + Error(ExceptionError(var ex)) => $"Exception: {ex.Message}", +}; +\`\`\` + +## See Also + +- [GetAsync](/api/getasync/) - Same signature +- [HttpClientExtensions](/api/httpclient-extensions/) - All extension methods +`; +} + +/** + * Generate PatchAsync METHOD DETAIL page + */ +function generatePatchAsync() { + return `--- +layout: layouts/api.njk +title: PatchAsync Method +description: Make a type-safe PATCH request for partial updates. +keywords: PatchAsync, HTTP PATCH, RestClient.Net, type-safe HTTP +eleventyNavigation: + key: PatchAsync + parent: HttpClient Extensions + order: 5 +permalink: /api/patchasync/ +--- + +Make a type-safe PATCH request for partial updates. + +## Namespace + +\`RestClient.Net\` + +## Containing Type + +[HttpClientExtensions](/api/httpclient-extensions/) + +## Signature + +Same signature as [PostAsync](/api/postasync/). + +\`\`\`csharp +public static async Task>> PatchAsync( + this HttpClient httpClient, + AbsoluteUrl url, + TRequest body, + Func serializeRequest, + Func> deserializeSuccess, + Func> deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +\`\`\` + +## Example + +\`\`\`csharp +var patch = new PatchUserRequest { Email = "new.email@example.com" }; + +var result = await httpClient.PatchAsync( + url: "https://api.example.com/users/123".ToAbsoluteUrl(), + body: patch, + serializeRequest: SerializeJson, + deserializeSuccess: DeserializeUser, + deserializeError: DeserializeApiError +); +\`\`\` + +## See Also + +- [PostAsync](/api/postasync/) - Same signature, for creating resources +- [PutAsync](/api/putasync/) - For full replacement +`; +} + +/** + * Generate Result Types page + */ +function generateResultTypes() { + return `--- +layout: layouts/api.njk +title: Result Types +description: Complete reference for RestClient.Net Result types - discriminated unions for type-safe HTTP error handling with pattern matching. +keywords: Result types, HttpError, discriminated unions, pattern matching, C# error handling +eleventyNavigation: + key: Result Types + parent: API Reference + order: 2 +permalink: /api/result-types/ +--- + +RestClient.Net uses [discriminated unions](${EXTERNAL_DOCS.record}) to represent HTTP responses. This forces you to handle all possible outcomes at compile time. + +## Result<TSuccess, TError> + +The core result type that represents either success or failure. + +\`\`\`csharp +public abstract record Result +{ + public record Ok(TSuccess Value) : Result; + public record Error(TError Value) : Result; +} +\`\`\` + +### Pattern Matching + +Use [switch expressions](${EXTERNAL_DOCS['switch expression']}) to handle all cases: + +\`\`\`csharp +var message = result switch +{ + Result>.Ok(var user) => + $"Got user: {user.Name}", + + Result>.Error(var error) => + $"Error occurred: {error}" +}; +\`\`\` + +## HttpError<TError> + +Represents HTTP-specific errors. Can be either a response error (server returned an error status) or an exception error (network failure, timeout, etc.). + +\`\`\`csharp +public abstract record HttpError +{ + public record ResponseError( + TError Error, + HttpStatusCode StatusCode, + HttpResponseHeaders Headers + ) : HttpError; + + public record ExceptionError(Exception Exception) : HttpError; +} +\`\`\` + +### ResponseError Properties + +| Property | Type | Description | +|----------|------|-------------| +| \`Error\` | \`TError\` | Your deserialized error model | +| \`StatusCode\` | [\`HttpStatusCode\`](${DOTNET_DOCS.HttpStatusCode}) | The HTTP status code (e.g., 404, 500) | +| \`Headers\` | \`HttpResponseHeaders\` | Response headers for accessing metadata | + +### ExceptionError Properties + +| Property | Type | Description | +|----------|------|-------------| +| \`Exception\` | [\`Exception\`](${DOTNET_DOCS.Exception}) | The caught exception (timeout, network error, etc.) | + +### Full Pattern Matching Example + +\`\`\`csharp +var message = result switch +{ + Result>.Ok(var user) => + $"Success: {user.Name}", + + Result>.Error( + HttpError.ResponseError(var err, var status, _)) => + $"API Error {status}: {err.Message}", + + Result>.Error( + HttpError.ExceptionError(var ex)) => + $"Exception: {ex.Message}", +}; +\`\`\` + +## Type Aliases + +The full type names are verbose. Define [global using aliases](${EXTERNAL_DOCS['global using']}) in \`GlobalUsings.cs\`: + +\`\`\`csharp +// GlobalUsings.cs - Define once, use everywhere + +// Result aliases +global using OkUser = Outcome.Result> + .Ok>; + +global using ErrorUser = Outcome.Result> + .Error>; + +// HttpError aliases +global using ResponseErrorUser = Outcome.HttpError.ResponseError; +global using ExceptionErrorUser = Outcome.HttpError.ExceptionError; +\`\`\` + +### Using Type Aliases + +With aliases defined, pattern matching becomes much cleaner: + +\`\`\`csharp +var message = result switch +{ + OkUser(var user) => $"Success: {user.Name}", + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"API Error {status}: {err.Message}", + ErrorUser(ExceptionErrorUser(var ex)) => $"Exception: {ex.Message}", +}; +\`\`\` + +## Exhaustion Analyzer + +The Exhaustion [Roslyn analyzer](${EXTERNAL_DOCS['Roslyn analyzer']}) ensures you handle all cases: + +\`\`\`csharp +// This won't compile! +var message = result switch +{ + OkUser(var user) => "Success", + ErrorUser(ResponseErrorUser(...)) => "API Error", + // COMPILE ERROR: Missing ExceptionError case! +}; +\`\`\` + +The compiler error: + +\`\`\` +error EXHAUSTION001: Switch on Result is not exhaustive; +Missing: Error> with ExceptionError +\`\`\` + +## Handling Specific Status Codes + +\`\`\`csharp +var message = result switch +{ + OkUser(var user) => $"Success: {user.Name}", + + ErrorUser(ResponseErrorUser(_, HttpStatusCode.NotFound, _)) => + "User not found", + + ErrorUser(ResponseErrorUser(_, HttpStatusCode.Unauthorized, _)) => + "Authentication required", + + ErrorUser(ResponseErrorUser(var err, var status, _)) => + $"Error {(int)status}: {err.Message}", + + ErrorUser(ExceptionErrorUser(var ex)) => + $"Network error: {ex.Message}", +}; +\`\`\` + +## See Also + +- [HttpClient Extensions](/api/httpclient-extensions/) - Extension methods that return Result types +- [Serialization](/api/serialization/) - Custom serialization and deserialization +`; +} + +/** + * Generate Serialization page + */ +function generateSerialization() { + return `--- +layout: layouts/api.njk +title: Serialization +description: Complete guide to serialization in RestClient.Net - JSON, custom serializers, request/response handling. +keywords: RestClient.Net serialization, JSON serialization, HttpContent, custom serializers +eleventyNavigation: + key: Serialization + parent: API Reference + order: 3 +permalink: /api/serialization/ +--- + +RestClient.Net gives you full control over how requests are serialized and responses are deserialized. + +## Deserializers + +Deserializers convert [\`HttpResponseMessage\`](${DOTNET_DOCS.HttpResponseMessage}) to your model types: + +\`\`\`csharp +Func> deserializeSuccess +Func> deserializeError +\`\`\` + +### JSON Deserialization + +Using \`System.Text.Json\`: + +\`\`\`csharp +var result = await httpClient.GetAsync( + url: "https://api.example.com/users/1".ToAbsoluteUrl(), + deserializeSuccess: async (response, ct) => + await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Null response"), + deserializeError: async (response, ct) => + await response.Content.ReadFromJsonAsync(ct) + ?? new ApiError("Unknown error") +); +\`\`\` + +### Reusable Deserializers + +Create a static class with reusable deserializer methods: + +\`\`\`csharp +public static class Deserializers +{ + public static async Task Json(HttpResponseMessage response, CancellationToken ct) + where T : class => + await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException($"Failed to deserialize {typeof(T).Name}"); + + public static async Task Error(HttpResponseMessage response, CancellationToken ct) => + await response.Content.ReadFromJsonAsync(ct) + ?? new ApiError("Unknown error"); +} + +// Usage +var result = await httpClient.GetAsync( + url: "https://api.example.com/users/1".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error +); +\`\`\` + +### Custom JSON Options + +Configure [\`JsonSerializerOptions\`](${DOTNET_DOCS.JsonSerializerOptions}) for custom serialization behavior: + +\`\`\`csharp +public static class Deserializers +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + + public static async Task Json(HttpResponseMessage response, CancellationToken ct) + where T : class => + await response.Content.ReadFromJsonAsync(Options, ct) + ?? throw new InvalidOperationException($"Failed to deserialize {typeof(T).Name}"); +} +\`\`\` + +## Serializers + +Serializers convert your request body to [\`HttpContent\`](${DOTNET_DOCS.HttpContent}). They're used with [POST](/api/httpclient-extensions/#postasync), [PUT](/api/httpclient-extensions/#putasync), and [PATCH](/api/httpclient-extensions/#patchasync) requests. + +\`\`\`csharp +Func serializeRequest +\`\`\` + +### JSON Serialization + +\`\`\`csharp +var result = await httpClient.PostAsync( + url: "https://api.example.com/users".ToAbsoluteUrl(), + body: new CreateUserRequest("John", "john@example.com"), + serializeRequest: body => JsonContent.Create(body), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error +); +\`\`\` + +### Custom Content Types + +#### Form URL Encoded + +Using [\`FormUrlEncodedContent\`](${DOTNET_DOCS.FormUrlEncodedContent}): + +\`\`\`csharp +serializeRequest: body => new FormUrlEncodedContent(new Dictionary +{ + ["name"] = body.Name, + ["email"] = body.Email +}) +\`\`\` + +#### Multipart Form Data + +Using [\`MultipartFormDataContent\`](${DOTNET_DOCS.MultipartFormDataContent}) for file uploads: + +\`\`\`csharp +serializeRequest: body => +{ + var content = new MultipartFormDataContent(); + content.Add(new StringContent(body.Name), "name"); + content.Add(new ByteArrayContent(body.FileBytes), "file", body.FileName); + return content; +} +\`\`\` + +#### XML + +Using [\`XmlSerializer\`](${DOTNET_DOCS.XmlSerializer}): + +\`\`\`csharp +serializeRequest: body => +{ + var serializer = new XmlSerializer(typeof(TBody)); + using var writer = new StringWriter(); + serializer.Serialize(writer, body); + return new StringContent(writer.ToString(), Encoding.UTF8, "application/xml"); +} +\`\`\` + +### Stream Response + +Using [\`Stream\`](${DOTNET_DOCS.Stream}) for large files: + +\`\`\`csharp +var result = await httpClient.GetAsync( + url: "https://api.example.com/files/large".ToAbsoluteUrl(), + deserializeSuccess: async (response, ct) => await response.Content.ReadAsStreamAsync(ct), + deserializeError: Deserializers.Error +); +\`\`\` + +## See Also + +- [HttpClient Extensions](/api/httpclient-extensions/) - Extension methods using serializers +- [Result Types](/api/result-types/) - Understanding the return types +`; +} + +/** + * Generate OpenAPI Generator page + */ +function generateOpenApiGenerator() { + return `--- +layout: layouts/docs.njk +title: OpenAPI Generator +lang: en +permalink: /api/openapi-generator/ +eleventyNavigation: + key: OpenAPI Generator + parent: API Reference + order: 5 +--- + +Generate type-safe C# clients from [OpenAPI 3.x](${EXTERNAL_DOCS.OpenAPI}) specifications. + +## Installation + +\`\`\`bash +dotnet add package RestClient.Net.OpenApiGenerator +\`\`\` + +## CLI Usage + +\`\`\`bash +dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \\ + -u api.yaml \\ + -o Generated \\ + -n YourApi.Generated +\`\`\` + +### CLI Options + +| Option | Short | Description | +|--------|-------|-------------| +| \`--openapi-url\` | \`-u\` | Path to OpenAPI spec (YAML or JSON) | +| \`--output\` | \`-o\` | Output directory for generated files | +| \`--namespace\` | \`-n\` | C# namespace for generated code | +| \`--client-name\` | \`-c\` | Prefix for generated client class names | + +## Generated Code + +The generator creates: + +1. **Model classes** - [Records](${EXTERNAL_DOCS.record}) for all schemas +2. **[HttpClient](/api/httpclient-extensions/) extension methods** - For each endpoint +3. **[Result type aliases](/api/result-types/#type-aliases)** - For concise pattern matching + +### Example Output + +For an OpenAPI spec with a \`/users/{id}\` endpoint: + +\`\`\`csharp +// Generated extension method +public static async Task GetUserById( + this HttpClient httpClient, + string id, + CancellationToken ct = default) +{ + return await httpClient.GetAsync( + url: $"https://api.example.com/users/{id}".ToAbsoluteUrl(), + deserializeSuccess: async (r, c) => await r.Content.ReadFromJsonAsync(c), + deserializeError: async (r, c) => await r.Content.ReadFromJsonAsync(c), + ct + ); +} + +// Generated type alias +global using ResultUser = Outcome.Result>; +global using OkUser = ResultUser.Ok>; +global using ErrorUser = ResultUser.Error>; +\`\`\` + +### Usage + +\`\`\`csharp +using YourApi.Generated; + +var httpClient = factory.CreateClient(); + +// Type-safe API call +var result = await httpClient.GetUserById("123"); + +// Pattern match on result +var output = result switch +{ + OkUser(var user) => $"Found: {user.Name}", + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"Error {status}", + ErrorUser(ExceptionErrorUser(var ex)) => $"Exception: {ex.Message}", +}; +\`\`\` + +## Supported OpenAPI Features + +- **HTTP Methods:** GET, POST, PUT, DELETE, PATCH +- **Parameters:** path, query, header +- **Request Bodies:** JSON, form data +- **Responses:** All status codes, multiple content types +- **Schemas:** objects, arrays, enums, [oneOf](https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/), [allOf](https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/), [anyOf](https://swagger.io/docs/specification/data-models/oneof-anyof-allof-not/) +- **References:** [$ref](https://swagger.io/docs/specification/using-ref/) for local and remote schemas + +## See Also + +- [HttpClient Extensions](/api/httpclient-extensions/) - The extension methods generated +- [Result Types](/api/result-types/) - Understanding the return types +- [MCP Generator](/api/mcp-generator/) - Generate AI tools from OpenAPI +`; +} + +/** + * Generate MCP Generator page + */ +function generateMcpGenerator() { + return `--- +layout: layouts/docs.njk +title: MCP Generator +lang: en +permalink: /api/mcp-generator/ +eleventyNavigation: + key: MCP Generator + parent: API Reference + order: 6 +--- + +Generate [Model Context Protocol (MCP)](${EXTERNAL_DOCS.MCP}) servers from OpenAPI specifications for AI integration with Claude Code and other tools. + +## Installation + +\`\`\`bash +dotnet add package RestClient.Net.McpGenerator +\`\`\` + +## Prerequisites + +First, generate the REST client using the [OpenAPI Generator](/api/openapi-generator/): + +\`\`\`bash +dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \\ + -u api.yaml -o Generated -n YourApi.Generated +\`\`\` + +## CLI Usage + +\`\`\`bash +dotnet run --project RestClient.Net.McpGenerator.Cli -- \\ + --openapi-url api.yaml \\ + --output-file Generated/McpTools.g.cs \\ + --namespace YourApi.Mcp \\ + --server-name YourApi \\ + --ext-namespace YourApi.Generated \\ + --tags "Search,Resources" +\`\`\` + +### CLI Options + +| Option | Description | +|--------|-------------| +| \`--openapi-url\` | Path to [OpenAPI](${EXTERNAL_DOCS.OpenAPI}) specification | +| \`--output-file\` | Output file for generated MCP tools | +| \`--namespace\` | C# namespace for MCP server | +| \`--server-name\` | Name of the MCP server | +| \`--ext-namespace\` | Namespace of generated REST client | +| \`--tags\` | OpenAPI tags to include (comma-separated) | + +## Generated Code + +The generator creates MCP tool definitions that wrap the [HttpClient extensions](/api/httpclient-extensions/): + +\`\`\`csharp +[McpServerToolType] +public static partial class McpTools +{ + [McpServerTool(Name = "get_user")] + [Description("Get user by ID")] + public static async Task GetUser( + [Description("User ID")] string id, + HttpClient httpClient, + CancellationToken ct) + { + var result = await httpClient.GetUserById(id, ct); + return result switch + { + OkUser(var user) => JsonSerializer.Serialize(user), + ErrorUser(var error) => $"Error: {error}" + }; + } +} +\`\`\` + +## Claude Code Integration + +Add to your Claude Code configuration: + +\`\`\`json +{ + "mcpServers": { + "yourapi": { + "command": "dotnet", + "args": ["run", "--project", "YourApi.McpServer"] + } + } +} +\`\`\` + +## Tool Naming + +OpenAPI operations are converted to MCP tool names: + +| OpenAPI | MCP Tool | +|---------|----------| +| \`GET /users/{id}\` | \`get_user\` | +| \`POST /users\` | \`create_user\` | +| \`PUT /users/{id}\` | \`update_user\` | +| \`DELETE /users/{id}\` | \`delete_user\` | + +## See Also + +- [OpenAPI Generator](/api/openapi-generator/) - Generate the REST client first +- [Result Types](/api/result-types/) - How results are handled +- [HttpClient Extensions](/api/httpclient-extensions/) - The underlying HTTP methods +`; +} + +/** + * Generate API index page + */ +function generateIndex() { + return `--- +layout: layouts/base.njk +title: API Reference +lang: en +permalink: /api/ +--- +
+

API Reference

+

Complete API documentation for RestClient.Net

+ +

Core API

+ + +

Code Generators

+ + +

Guides

+ + +

NuGet Packages

+ + + + + + + + + + + + + + + + + + + + + + + + + +
PackageDescription
RestClient.NetCore library with HttpClient extensions
RestClient.Net.OpenApiGeneratorOpenAPI 3.x client generator
RestClient.Net.McpGeneratorMCP server generator
ExhaustionRoslyn analyzer for switch exhaustiveness
+ +

External References

+ +
+`; +} + +/** + * Main entry point + */ +function main() { + console.log('Generating API documentation for RestClient.Net...'); + console.log(`Source: ${RESTCLIENT_NET_DIR}`); + console.log(`Output: ${API_OUTPUT_DIR}`); + + // Ensure output directory exists + fs.mkdirSync(API_OUTPUT_DIR, { recursive: true }); + + // Generate all pages + // Class summary pages link to individual member detail pages + const pages = [ + { file: 'index.njk', content: generateIndex() }, + // HttpClientExtensions class summary + individual method pages + { file: 'httpclient-extensions.md', content: generateHttpClientExtensions() }, + { file: 'getasync.md', content: generateGetAsync() }, + { file: 'postasync.md', content: generatePostAsync() }, + { file: 'putasync.md', content: generatePutAsync() }, + { file: 'deleteasync.md', content: generateDeleteAsync() }, + { file: 'patchasync.md', content: generatePatchAsync() }, + // Other pages + { file: 'result-types.md', content: generateResultTypes() }, + { file: 'serialization.md', content: generateSerialization() }, + { file: 'openapi-generator.md', content: generateOpenApiGenerator() }, + { file: 'mcp-generator.md', content: generateMcpGenerator() }, + ]; + + for (const { file, content } of pages) { + const outputPath = path.join(API_OUTPUT_DIR, file); + fs.writeFileSync(outputPath, content); + console.log(` Generated: ${file}`); + } + + console.log('\n=== API documentation generation complete ==='); +} + +main(); diff --git a/Website/scripts/generate-api-docs.sh b/Website/scripts/generate-api-docs.sh new file mode 100755 index 00000000..decb7363 --- /dev/null +++ b/Website/scripts/generate-api-docs.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# Generate API documentation for RestClient.Net +# This script generates markdown files from C# source in the RestClient.Net repo + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WEBSITE_DIR="$(dirname "$SCRIPT_DIR")" + +cd "$WEBSITE_DIR" + +# Run the Node.js generator +node "$SCRIPT_DIR/generate-api-docs.js" diff --git a/Website/src/_data/i18n.json b/Website/src/_data/i18n.json new file mode 100644 index 00000000..4cf08579 --- /dev/null +++ b/Website/src/_data/i18n.json @@ -0,0 +1,36 @@ +{ + "en": { + "blog": { + "back": "← Back to Blog", + "title": "Blog", + "subtitle": "Latest updates and articles about RestClient.Net" + }, + "nav": { + "apiReference": "API Reference", + "docs": "Documentation", + "blog": "Blog", + "api": "API" + }, + "docs": { + "title": "Documentation", + "gettingStarted": "Getting Started" + } + }, + "zh": { + "blog": { + "back": "← 返回博客", + "title": "博客", + "subtitle": "RestClient.Net 的最新更新和文章" + }, + "nav": { + "apiReference": "API 参考", + "docs": "文档", + "blog": "博客", + "api": "API" + }, + "docs": { + "title": "文档", + "gettingStarted": "入门指南" + } + } +} diff --git a/Website/src/_data/languages.json b/Website/src/_data/languages.json new file mode 100644 index 00000000..787e9fc4 --- /dev/null +++ b/Website/src/_data/languages.json @@ -0,0 +1,12 @@ +{ + "en": { + "code": "en", + "name": "English", + "nativeName": "English" + }, + "zh": { + "code": "zh", + "name": "Chinese", + "nativeName": "中文" + } +} diff --git a/Website/src/_data/navigation.json b/Website/src/_data/navigation.json new file mode 100644 index 00000000..d817e800 --- /dev/null +++ b/Website/src/_data/navigation.json @@ -0,0 +1,167 @@ +{ + "main": [ + { + "text": "Home", + "url": "/" + }, + { + "text": "Docs", + "url": "/docs/" + }, + { + "text": "API", + "url": "/api/" + }, + { + "text": "Examples", + "url": "/examples/" + }, + { + "text": "Blog", + "url": "/blog/" + }, + { + "text": "GitHub", + "url": "https://github.com/MelbourneDeveloper/RestClient.Net", + "external": true + } + ], + "docs": [ + { + "title": "Getting Started", + "items": [ + { + "text": "Introduction", + "url": "/docs/" + }, + { + "text": "Installation", + "url": "/docs/installation/" + }, + { + "text": "Basic Usage", + "url": "/docs/basic-usage/" + }, + { + "text": "Error Handling", + "url": "/docs/error-handling/" + }, + { + "text": "Advanced Usage", + "url": "/docs/advanced-usage/" + } + ] + }, + { + "title": "Tools", + "items": [ + { + "text": "OpenAPI Generator", + "url": "/docs/openapi/" + }, + { + "text": "MCP Server", + "url": "/docs/mcp/" + }, + { + "text": "Exhaustion Analyzer", + "url": "/docs/exhaustion/" + } + ] + }, + { + "title": "API Reference", + "items": [ + { + "text": "Overview", + "url": "/api/" + }, + { + "text": "HTTP Methods", + "url": "/api/http-methods/" + }, + { + "text": "HttpClient Extensions", + "url": "/api/httpclient-extensions/" + }, + { + "text": "Result Types", + "url": "/api/result-types/" + }, + { + "text": "Serialization", + "url": "/api/serialization/" + }, + { + "text": "URL Handling", + "url": "/api/url-handling/" + } + ] + } + ], + "footer": [ + { + "title": "Documentation", + "items": [ + { + "text": "Getting Started", + "url": "/docs/" + }, + { + "text": "Installation", + "url": "/docs/installation/" + }, + { + "text": "Error Handling", + "url": "/docs/error-handling/" + }, + { + "text": "OpenAPI Generator", + "url": "/docs/openapi/" + }, + { + "text": "MCP Server", + "url": "/docs/mcp/" + } + ] + }, + { + "title": "Community", + "items": [ + { + "text": "GitHub", + "url": "https://github.com/MelbourneDeveloper/RestClient.Net" + }, + { + "text": "NuGet", + "url": "https://www.nuget.org/packages/RestClient.Net" + }, + { + "text": "Twitter", + "url": "https://twitter.com/caborean" + } + ] + }, + { + "title": "Resources", + "items": [ + { + "text": "Blog", + "url": "/blog/" + }, + { + "text": "Code Examples", + "url": "/examples/" + }, + { + "text": "API Reference", + "url": "/api/" + }, + { + "text": "Exhaustion Analyzer", + "url": "https://www.nuget.org/packages/Exhaustion" + } + ] + } + ] +} diff --git a/Website/src/_data/site.json b/Website/src/_data/site.json new file mode 100644 index 00000000..382886d3 --- /dev/null +++ b/Website/src/_data/site.json @@ -0,0 +1,12 @@ +{ + "name": "RestClient.Net", + "title": "RestClient.Net", + "description": "The safest way to make REST calls in C#. Built with functional programming, type safety, and modern .NET patterns.", + "url": "https://restclient.net", + "stylesheet": "/assets/css/styles.css", + "author": "Christian Findlay", + "twitterSite": "@caborean", + "ogImage": "/assets/images/Logo.jpg", + "themeColor": "#3D9970", + "keywords": "C#, .NET, REST, HTTP, API, functional programming, type safety, HttpClient" +} diff --git a/Website/src/assets/css/styles.css b/Website/src/assets/css/styles.css new file mode 100644 index 00000000..a9c41eed --- /dev/null +++ b/Website/src/assets/css/styles.css @@ -0,0 +1,1461 @@ +/* RestClient.Net Theme + Colors from Logo.jpg - Teal/Seafoam Green (#3D9970) + White + Based on dart_node website styling patterns for consistency +*/ + +/* ============================================ + CSS CUSTOM PROPERTIES + ============================================ */ + +:root { + /* RestClient.Net brand colors - from logo */ + --color-primary: #3D9970; + --color-primary-light: #5AB88E; + --color-primary-dark: #2D7A56; + --color-secondary: #E85D3B; + --color-secondary-light: #F07858; + --color-secondary-dark: #C74A2A; + --color-accent: #2C89C7; + --color-accent-light: #4AA3E0; + --color-accent-dark: #1E6A9E; + + /* Neutral colors */ + --color-gray-50: #FAFBFC; + --color-gray-100: #F3F4F6; + --color-gray-200: #E5E7EB; + --color-gray-300: #D1D5DB; + --color-gray-400: #9CA3AF; + --color-gray-500: #6B7280; + --color-gray-600: #4B5563; + --color-gray-700: #374151; + --color-gray-800: #1F2937; + --color-gray-900: #111827; + + /* Semantic colors */ + --color-success: #10B981; + --color-warning: #F59E0B; + --color-error: #EF4444; + --color-info: #3B82F6; + + /* Light theme (default) */ + --bg-primary: var(--color-gray-50); + --bg-secondary: #FFFFFF; + --bg-tertiary: var(--color-gray-100); + --text-primary: var(--color-gray-900); + --text-secondary: var(--color-gray-600); + --text-tertiary: var(--color-gray-500); + --border-color: var(--color-gray-200); + --code-bg: var(--color-gray-100); + + /* Typography */ + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace; + + /* Font sizes */ + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 1.875rem; + --text-4xl: 2.25rem; + --text-5xl: 3rem; + + /* Spacing */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-5: 1.25rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-10: 2.5rem; + --space-12: 3rem; + --space-16: 4rem; + --space-20: 5rem; + + /* Border radius */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-full: 9999px; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + + /* Transitions */ + --transition-fast: 150ms ease; + --transition-base: 200ms ease; + --transition-slow: 300ms ease; + --transition-smooth: 250ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Layout */ + --max-width: 1200px; + --header-height: 64px; + --sidebar-width: 280px; + --content-width: 720px; +} + +/* Dark theme */ +[data-theme="dark"] { + --bg-primary: #0F1419; + --bg-secondary: #1A1F26; + --bg-tertiary: #242B33; + --text-primary: #F3F4F6; + --text-secondary: #9CA3AF; + --text-tertiary: #6B7280; + --border-color: #374151; + --code-bg: #1A1F26; +} + +/* ============================================ + BASE STYLES + ============================================ */ + +html { + scroll-behavior: smooth; + scroll-padding-top: calc(var(--header-height) + var(--space-4)); +} + +body { + font-family: var(--font-sans); + font-size: var(--text-base); + line-height: 1.6; + color: var(--text-primary); + background-color: var(--bg-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ============================================ + TYPOGRAPHY + ============================================ */ + +h1, h2, h3, h4, h5, h6 { + font-weight: 600; + line-height: 1.3; + color: var(--text-primary); +} + +h1 { + font-size: var(--text-4xl); + font-weight: 700; + letter-spacing: -0.02em; +} + +h2 { + font-size: var(--text-3xl); + letter-spacing: -0.01em; +} + +h3 { + font-size: var(--text-2xl); +} + +h4 { + font-size: var(--text-xl); +} + +h5 { + font-size: var(--text-lg); +} + +h6 { + font-size: var(--text-base); +} + +p { + margin-bottom: var(--space-4); + color: var(--text-secondary); +} + +a { + color: var(--color-primary); + text-decoration: none; + transition: color var(--transition-fast); +} + +a:hover { + color: var(--color-primary-light); +} + +strong { + font-weight: 600; +} + +/* Lists */ +ul, ol { + margin-bottom: var(--space-4); + padding-left: var(--space-6); +} + +li { + margin-bottom: var(--space-2); + color: var(--text-secondary); +} + +/* ============================================ + CODE + ============================================ */ + +code { + font-family: var(--font-mono); + font-size: 0.9em; + padding: 0.2em 0.4em; + background: var(--code-bg); + border-radius: var(--radius-sm); + color: var(--color-secondary); + word-break: break-word; +} + +pre { + font-family: var(--font-mono); + font-size: var(--text-sm); + line-height: 1.7; + padding: var(--space-4); + background: var(--code-bg); + border-radius: var(--radius-lg); + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin-bottom: var(--space-4); +} + +pre code { + padding: 0; + background: none; + color: inherit; + word-break: normal; + white-space: pre; + display: block; +} + +/* ============================================ + SKIP LINK + ============================================ */ + +.skip-link { + background: var(--color-primary); + color: white; + border-radius: var(--radius-md); +} + +.skip-link:focus { + color: white; +} + +/* ============================================ + HEADER & NAVIGATION + ============================================ */ + +.site-header { + height: var(--header-height); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); +} + +.nav { + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + max-width: var(--max-width); + margin-inline: auto; + padding: 0 var(--space-4); +} + +.nav-actions { + display: flex; + align-items: center; + gap: var(--space-3); + flex-shrink: 0; +} + +.logo { + font-weight: 700; + font-size: var(--text-xl); + color: var(--text-primary); + transition: all var(--transition-fast); +} + +.logo:hover { + color: var(--color-primary); + transform: scale(1.03); +} + +.nav-links { + list-style: none; + margin: 0; + padding: 0; + display: flex; + align-items: center; + gap: var(--space-1); +} + +.nav-links li { + margin: 0; +} + +.nav-link { + display: inline-block; + font-weight: 500; + color: var(--text-secondary); + transition: color var(--transition-fast); + position: relative; + padding: var(--space-2) var(--space-3); +} + +.nav-link::after { + content: ''; + position: absolute; + bottom: 0; + left: var(--space-3); + right: var(--space-3); + height: 2px; + background: var(--color-primary); + transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); + transform: scaleX(0); + border-radius: 2px; +} + +.nav-link:hover::after, +.nav-link.active::after { + transform: scaleX(1); +} + +.nav-link:hover, +.nav-link.active { + color: var(--color-primary); +} + +/* ============================================ + LANGUAGE SWITCHER + ============================================ */ + +.language-btn { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2) var(--space-3); + height: 40px; + background: transparent; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); + font-family: var(--font-sans); + font-size: var(--text-sm); + font-weight: 500; +} + +.language-btn:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + border-color: var(--color-primary); +} + +.language-dropdown { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + box-shadow: var(--shadow-lg); + padding: var(--space-2); + margin-top: var(--space-2); +} + +.language-dropdown li { + margin: 0; +} + +.language-dropdown a { + display: block; + padding: var(--space-2) var(--space-3); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: var(--text-sm); + transition: all var(--transition-fast); +} + +.language-dropdown a:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.language-dropdown a.active { + background: var(--color-primary); + color: white; +} + +/* ============================================ + THEME TOGGLE + ============================================ */ + +.theme-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: transparent; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + color: var(--text-secondary); + cursor: pointer; + transition: all var(--transition-fast); +} + +.theme-toggle:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +[data-theme="light"] .theme-icon-dark { + display: none; +} + +[data-theme="dark"] .theme-icon-light { + display: none; +} + +/* ============================================ + MOBILE MENU + ============================================ */ + +.mobile-menu-toggle span { + background: var(--text-primary); + transition: all var(--transition-fast); +} + +@media (max-width: 768px) { + .nav-links { + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + } +} + +/* ============================================ + BUTTONS + ============================================ */ + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--space-2); + padding: var(--space-3) var(--space-6); + font-family: var(--font-sans); + font-size: var(--text-base); + font-weight: 500; + text-decoration: none; + border-radius: var(--radius-md); + border: none; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +.btn:hover { + transform: translateY(-4px) scale(1.02); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); +} + +.btn:active { + transform: translateY(-1px) scale(1); + transition-duration: 0.1s; +} + +.btn-primary { + background: var(--color-primary); + color: white; +} + +.btn-primary:hover { + background: var(--color-primary-light); + color: white; + box-shadow: 0 10px 30px rgba(61, 153, 112, 0.5); +} + +.btn-secondary { + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background: var(--bg-tertiary); +} + +.btn-large { + padding: var(--space-4) var(--space-8); + font-size: var(--text-lg); +} + +.btn-prev, +.btn-next { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + padding: var(--space-2) var(--space-4); + border-radius: var(--radius-md); + font-weight: 500; + transition: all var(--transition-fast); +} + +.btn-prev:hover, +.btn-next:hover { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); +} + +/* ============================================ + HERO SECTION + ============================================ */ + +.hero { + padding: var(--space-20) 0; + text-align: center; + background: linear-gradient(135deg, #1a2f38 0%, #0d1f26 50%, #162329 100%); + position: relative; + overflow: hidden; +} + +.hero::before { + content: ''; + position: absolute; + top: -50%; + left: -25%; + width: 80%; + height: 150%; + background: radial-gradient(ellipse, rgba(61, 153, 112, 0.25) 0%, transparent 60%); + animation: float 15s ease-in-out infinite; +} + +.hero::after { + content: ''; + position: absolute; + bottom: -50%; + right: -25%; + width: 80%; + height: 150%; + background: radial-gradient(ellipse, rgba(46, 204, 113, 0.15) 0%, transparent 60%); + animation: float 18s ease-in-out infinite reverse; +} + +@keyframes float { + 0%, 100% { transform: translate(0, 0) rotate(0deg); } + 33% { transform: translate(30px, -30px) rotate(5deg); } + 66% { transform: translate(-20px, 20px) rotate(-5deg); } +} + +.hero .container { + position: relative; + z-index: 1; +} + +.hero-logo { + max-width: 280px; + height: auto; + margin: 0 auto var(--space-8); + border-radius: var(--radius-xl); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.hero h1 { + font-size: var(--text-5xl); + margin-bottom: var(--space-6); + color: #fff; + text-shadow: 0 4px 30px rgba(0, 0, 0, 0.3); +} + +.hero h1 span { + color: var(--color-primary-light); +} + +.hero-tagline { + font-size: var(--text-xl); + color: rgba(255, 255, 255, 0.85); + max-width: 42rem; + margin: 0 auto var(--space-8); + line-height: 1.8; +} + +.hero-actions { + display: flex; + gap: var(--space-4); + justify-content: center; + flex-wrap: wrap; +} + +.hero .btn-secondary { + background: rgba(255, 255, 255, 0.15); + color: white; + border: 2px solid rgba(255, 255, 255, 0.4); + backdrop-filter: blur(10px); +} + +.hero .btn-secondary:hover { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.6); + color: white; +} + +/* ============================================ + CONTAINER + ============================================ */ + +.container { + width: 100%; + max-width: var(--max-width); + margin: 0 auto; + padding: 0 var(--space-4); +} + +/* ============================================ + FEATURES + ============================================ */ + +.features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--space-6); + padding: var(--space-16) 0; +} + +.feature-card { + padding: var(--space-6); + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + transition: all 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94); + position: relative; + overflow: hidden; +} + +.feature-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%); + transform: scaleX(0); + transform-origin: left; + transition: transform var(--transition-base); +} + +.feature-card:hover { + transform: translateY(-8px); + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); + border-color: var(--color-primary); +} + +.feature-card:hover::before { + transform: scaleX(1); +} + +.feature-card h3 { + margin-bottom: var(--space-2); + color: var(--color-primary); +} + +.feature-card p { + margin: 0; + color: var(--text-secondary); +} + +.feature-card code { + background: var(--bg-tertiary); + color: var(--color-secondary); + padding: 0.15em 0.4em; + border-radius: var(--radius-sm); + font-size: 0.85em; +} + +/* Code blocks inside feature cards (e.g., Why Discriminated Unions section) */ +.feature-card pre { + background: var(--bg-tertiary); + border-radius: var(--radius-md); + padding: var(--space-4); + margin: var(--space-4) 0 0 0; + overflow-x: auto; + border: none; +} + +.feature-card pre code { + background: none; + padding: 0; + font-size: inherit; + color: var(--text-primary); + display: block; + white-space: pre; + word-break: normal; +} + +/* ============================================ + DOCS LAYOUT + ============================================ */ + +.docs-layout { + min-height: calc(100vh - var(--header-height)); +} + +.sidebar { + background: var(--bg-secondary); + border-right: 1px solid var(--border-color); + padding: var(--space-6); +} + +.docs-nav ul { + list-style: none; + padding: 0; + margin: 0; +} + +.docs-nav li { + margin: 0; +} + +.docs-nav a { + display: block; + padding: var(--space-2) var(--space-3); + color: var(--text-secondary); + border-radius: var(--radius-md); + transition: all var(--transition-fast); + border-left: 3px solid transparent; + margin-left: -3px; +} + +.docs-nav a:hover, +.docs-nav a.active { + background: var(--bg-tertiary); + color: var(--color-primary); + border-left-color: var(--color-primary); + padding-left: calc(var(--space-3) + 6px); +} + +.docs-content { + padding: var(--space-8); +} + +.docs-content h1 { + margin-bottom: var(--space-6); +} + +.docs-content h1:first-child { + padding-bottom: var(--space-4); + border-bottom: 2px solid var(--border-color); + position: relative; +} + +.docs-content h1:first-child::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 80px; + height: 2px; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%); +} + +.docs-content h2 { + margin-top: var(--space-10); + margin-bottom: var(--space-4); + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--border-color); + position: relative; +} + +.docs-content h2::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + width: 60px; + height: 1px; + background: var(--color-primary); +} + +.docs-content h3 { + margin-top: var(--space-8); + margin-bottom: var(--space-3); +} + +.docs-content ul, +.docs-content ol { + padding-left: var(--space-6); + margin-bottom: var(--space-4); +} + +.docs-content li { + margin-bottom: var(--space-2); +} + +.docs-content blockquote { + border-left: 4px solid var(--color-primary); + padding: var(--space-4) var(--space-6); + margin: var(--space-6) 0; + background: var(--bg-tertiary); + border-radius: 0 var(--radius-lg) var(--radius-lg) 0; +} + +.docs-content blockquote p { + margin: 0; + color: var(--text-primary); +} + +.docs-content table { + width: 100%; + border-collapse: collapse; + margin: var(--space-6) 0; + border-radius: var(--radius-lg); + overflow: hidden; +} + +.docs-content th, +.docs-content td { + padding: var(--space-3) var(--space-4); + border: 1px solid var(--border-color); + text-align: left; +} + +.docs-content th { + background: var(--bg-tertiary); + font-weight: 600; +} + +.docs-content img { + max-width: 100%; + height: auto; + border-radius: var(--radius-lg); + margin: var(--space-6) 0; + box-shadow: var(--shadow-md); +} + +/* Header anchors */ +.header-anchor { + color: var(--text-primary); + text-decoration: none; +} + +.header-anchor:hover { + color: var(--color-primary); +} + +/* Docs nav footer */ +.docs-nav-footer { + border-top: 1px solid var(--border-color); +} + +/* ============================================ + BLOG + ============================================ */ + +.blog-container { + max-width: var(--content-width); + margin: 0 auto; + padding: var(--space-12) var(--space-4); +} + +.blog-header { + text-align: center; + margin-bottom: var(--space-12); + padding-bottom: var(--space-8); + border-bottom: 1px solid var(--border-color); +} + +.blog-logo { + width: 100px; + height: auto; + border-radius: var(--radius-lg); + margin-bottom: var(--space-4); +} + +.blog-header h1 { + margin-bottom: var(--space-2); + font-size: var(--text-3xl); +} + +.blog-subtitle { + color: var(--text-tertiary); + margin: 0; +} + +.post-list { + list-style: none; + padding: 0; + margin: 0; +} + +.post-list .blog-post { + padding: var(--space-6) 0; + border-bottom: 1px solid var(--border-color); +} + +.post-list .blog-post:last-child { + border-bottom: none; +} + +.post-title { + font-size: var(--text-xl); + font-weight: 600; + color: var(--text-primary); + display: block; + margin-bottom: var(--space-1); + transition: color var(--transition-fast); +} + +.post-title:hover { + color: var(--color-primary); +} + +.post-meta { + color: var(--text-tertiary); + font-size: var(--text-sm); + margin-bottom: var(--space-2); +} + +.post-excerpt { + color: var(--text-secondary); + margin: 0; + line-height: 1.6; +} + +/* Single blog post page */ +article.blog-post { + padding: 0; +} + +article.blog-post .blog-container { + padding: var(--space-8) var(--space-4); +} + +.blog-post-header { + margin-bottom: var(--space-6); + padding-bottom: var(--space-4); + border-bottom: 1px solid var(--border-color); +} + +.blog-post-header h1 { + font-size: var(--text-3xl); + margin-bottom: var(--space-2); + line-height: 1.2; +} + +.blog-post-meta { + color: var(--text-tertiary); + font-size: var(--text-sm); + margin: 0; +} + +.blog-post-tags { + margin-top: var(--space-3); +} + +.blog-post-content { + max-width: var(--content-width); +} + +/* Remove duplicate h1 from markdown content */ +.blog-post-content h1:first-child { + display: none; +} + +.blog-post-content h2 { + font-size: var(--text-2xl); + margin-top: var(--space-8); + margin-bottom: var(--space-3); +} + +.blog-post-content h3 { + font-size: var(--text-xl); + margin-top: var(--space-6); + margin-bottom: var(--space-2); +} + +.blog-post-content p { + margin-bottom: var(--space-4); +} + +.blog-post-content pre { + margin: var(--space-4) 0; +} + +.blog-post-footer { + margin-top: var(--space-8); + padding-top: var(--space-4); + border-top: 1px solid var(--border-color); +} + +/* Tags */ +.tag { + display: inline-block; + padding: var(--space-1) var(--space-3); + font-size: var(--text-xs); + font-weight: 500; + background: var(--color-primary); + color: white; + border-radius: var(--radius-full); +} + +.tag-secondary { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +a.tag-secondary:hover { + background: var(--color-primary); + color: white; +} + +/* ============================================ + FOOTER + ============================================ */ + +.site-footer { + background: var(--bg-secondary); + border-top: 1px solid var(--border-color); + position: relative; +} + +.site-footer::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-light) 100%); +} + +.footer-section h3 { + font-size: var(--text-sm); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-4); + color: var(--text-tertiary); +} + +.footer-section ul { + list-style: none; + padding: 0; + margin: 0; +} + +.footer-section li { + margin-bottom: var(--space-2); +} + +.footer-section a { + color: var(--text-secondary); + font-size: var(--text-sm); +} + +.footer-section a:hover { + color: var(--color-primary); +} + +.footer-bottom { + border-top: 1px solid var(--border-color); +} + +.footer-bottom p { + font-size: var(--text-sm); + color: var(--text-tertiary); + margin: 0; +} + +/* ============================================ + SYNTAX HIGHLIGHTING (Prism) + ============================================ */ + +.token.comment, +.token.prolog, +.token.doctype, +.token.cdata { + color: var(--color-gray-500); +} + +.token.punctuation { + color: var(--text-secondary); +} + +.token.property, +.token.tag, +.token.boolean, +.token.number, +.token.constant, +.token.symbol, +.token.deleted { + color: var(--color-secondary); +} + +.token.selector, +.token.attr-name, +.token.string, +.token.char, +.token.builtin, +.token.inserted { + color: var(--color-success); +} + +.token.operator, +.token.entity, +.token.url, +.language-css .token.string, +.style .token.string { + color: var(--color-warning); +} + +.token.atrule, +.token.attr-value, +.token.keyword { + color: var(--color-accent); +} + +.token.function, +.token.class-name { + color: var(--color-primary); +} + +.token.regex, +.token.important, +.token.variable { + color: var(--color-secondary); +} + +/* ============================================ + UTILITIES + ============================================ */ + +.text-center { text-align: center; } +.text-left { text-align: left; } +.text-right { text-align: right; } + +.mt-0 { margin-top: 0; } +.mt-sm { margin-top: var(--space-2); } +.mt-md { margin-top: var(--space-4); } +.mt-lg { margin-top: var(--space-6); } +.mt-xl { margin-top: var(--space-8); } +.mt-2xl { margin-top: var(--space-12); } + +.mb-0 { margin-bottom: 0; } +.mb-sm { margin-bottom: var(--space-2); } +.mb-md { margin-bottom: var(--space-4); } +.mb-lg { margin-bottom: var(--space-6); } +.mb-xl { margin-bottom: var(--space-8); } +.mb-2xl { margin-bottom: var(--space-12); } + +.flex { display: flex; } +.flex-wrap { flex-wrap: wrap; } +.items-center { align-items: center; } +.justify-center { justify-content: center; } +.justify-between { justify-content: space-between; } +.gap-xs { gap: var(--space-1); } +.gap-sm { gap: var(--space-2); } +.gap-md { gap: var(--space-4); } +.gap-lg { gap: var(--space-6); } + +.hidden { display: none; } +.block { display: block; } +.grid { display: grid; } + +/* ============================================ + RESPONSIVE + ============================================ */ + +@media (max-width: 1024px) { + .sidebar { + position: fixed; + top: var(--header-height); + left: 0; + width: var(--sidebar-width); + height: calc(100vh - var(--header-height)); + z-index: 50; + transform: translateX(-100%); + } + + .sidebar.open { + transform: translateX(0); + } +} + +@media (max-width: 768px) { + :root { + --text-5xl: 2rem; + --text-4xl: 1.75rem; + --text-3xl: 1.375rem; + --text-2xl: 1.25rem; + } + + .container { + padding: 0 var(--space-3); + } + + .hero { + padding: var(--space-10) 0; + } + + .hero h1 { + font-size: var(--text-4xl); + } + + .hero h1 br { + display: none; + } + + .hero-tagline { + font-size: var(--text-base); + } + + .hero-actions { + flex-direction: column; + align-items: stretch; + } + + .hero-logo { + max-width: 200px; + } + + pre { + font-size: var(--text-xs); + padding: var(--space-3); + border-radius: var(--radius-md); + } + + .features { + grid-template-columns: 1fr; + gap: var(--space-4); + padding: var(--space-10) 0; + } + + .feature-card { + padding: var(--space-4); + } + + .blog-container, + .blog-post { + padding: var(--space-10) var(--space-4); + } + + .docs-content { + padding: var(--space-4); + } + + table { + font-size: var(--text-sm); + } + + th, td { + padding: var(--space-2); + } + + .nav-actions { + gap: var(--space-2); + } + + .theme-toggle { + width: 36px; + height: 36px; + } + + .language-btn { + padding: var(--space-1) var(--space-2); + font-size: var(--text-xs); + height: 36px; + } +} + +@media (max-width: 480px) { + :root { + --text-5xl: 1.75rem; + --text-4xl: 1.5rem; + --text-3xl: 1.25rem; + --text-2xl: 1.125rem; + --text-xl: 1rem; + } + + .container { + padding: 0 var(--space-4); + } + + .hero { + padding: var(--space-8) 0; + } + + pre { + font-size: 0.65rem; + padding: var(--space-2); + } + + .btn { + padding: var(--space-2) var(--space-4); + font-size: var(--text-sm); + } + + .btn-large { + padding: var(--space-3) var(--space-5); + font-size: var(--text-base); + } + + .language-btn span:not(.chevron) { + display: none; + } + + .language-dropdown { + right: 0; + left: auto; + min-width: 120px; + } +} + +/* ============================================ + ANIMATIONS + ============================================ */ + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation: none !important; + transition-duration: 0.01ms !important; + } +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.animate-fade-in { + animation: fadeIn 0.5s ease-out; +} + +/* ============================================ + API REFERENCE + ============================================ */ + +.api-reference { + max-width: var(--content-width); + margin: 0 auto; + padding: var(--space-8) var(--space-4); +} + +.api-reference h1 { + margin-bottom: var(--space-2); +} + +.api-subtitle { + color: var(--text-tertiary); + margin-bottom: var(--space-8); +} + +.api-package { + margin-bottom: var(--space-8); + padding-bottom: var(--space-6); + border-bottom: 1px solid var(--border-color); +} + +.api-package:last-of-type { + border-bottom: none; +} + +.api-package h2 { + font-size: var(--text-xl); + margin-bottom: var(--space-2); +} + +.api-package h2 a { + color: var(--text-primary); +} + +.api-package h2 a:hover { + color: var(--color-primary); +} + +.package-install { + margin-bottom: var(--space-4); +} + +.package-install code { + background: var(--bg-tertiary); + padding: var(--space-1) var(--space-2); + border-radius: var(--radius-sm); + font-size: var(--text-sm); +} + +.api-package h3 { + font-size: var(--text-base); + color: var(--text-secondary); + margin-bottom: var(--space-3); + font-weight: 500; +} + +.api-package h3 code { + color: var(--color-primary); + background: none; + font-weight: 600; +} + +.api-list { + margin: 0; + padding: 0; +} + +.api-list dt { + font-weight: 600; + margin-top: var(--space-3); +} + +.api-list dt a { + color: var(--color-primary); +} + +.api-list dd { + margin-left: 0; + color: var(--text-secondary); + font-size: var(--text-sm); +} + +.api-list dd ul { + margin: var(--space-1) 0 0 var(--space-4); + padding: 0; +} + +.api-list dd li { + margin-bottom: var(--space-1); +} + +.api-list dd li a { + color: var(--text-primary); + font-family: var(--font-mono); + font-size: var(--text-xs); +} + +.api-list dd li a:hover { + color: var(--color-primary); +} + +.api-external { + margin-top: var(--space-8); +} + +.api-external h2 { + font-size: var(--text-lg); + margin-bottom: var(--space-3); +} + +.api-external ul { + margin: 0; + padding: 0; + list-style: none; +} + +.api-external li { + margin-bottom: var(--space-2); + font-size: var(--text-sm); +} diff --git a/Website/src/assets/images/Logo.jpg b/Website/src/assets/images/Logo.jpg new file mode 100644 index 00000000..cad06fa4 Binary files /dev/null and b/Website/src/assets/images/Logo.jpg differ diff --git a/Website/src/blog/announcing-v6.md b/Website/src/blog/announcing-v6.md new file mode 100644 index 00000000..37c00011 --- /dev/null +++ b/Website/src/blog/announcing-v6.md @@ -0,0 +1,62 @@ +--- +layout: layouts/blog.njk +title: Announcing RestClient.Net v6 - Type-Safe REST for C# +date: 2026-01-20 +author: Christian Findlay +tags: posts +excerpt: RestClient.Net v6 brings discriminated unions to C# REST calls with compile-time exhaustiveness checking. +--- + +# Announcing RestClient.Net v6 + +We're excited to announce RestClient.Net v6, a major release that brings functional programming patterns to C# REST calls. + +## What's New + +### Result Types Instead of Exceptions + +Gone are the days of try-catch blocks around HTTP calls. RestClient.Net v6 returns `Result>` - a discriminated union that forces you to handle all outcomes: + +```csharp +var result = await httpClient + .GetAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializePost, + deserializeError: DeserializeError + ); + +var output = result switch +{ + OkPost(var post) => $"Success: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"Error {status}", + ErrorPost(ExceptionErrorPost(var ex)) => $"Exception: {ex.Message}", +}; +``` + +### Exhaustiveness Checking + +The Exhaustion analyzer ensures you don't miss any cases. If you forget to handle `ExceptionErrorPost`, your code won't compile: + +``` +error EXHAUSTION001: Switch on Result is not exhaustive; +Missing: Error> with ExceptionError +``` + +### OpenAPI Generator + +Generate type-safe clients from OpenAPI 3.x specs: + +```bash +dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \ + -u api.yaml -o Generated -n YourApi.Generated +``` + +## Getting Started + +Install via NuGet: + +```bash +dotnet add package RestClient.Net +``` + +Check out our [documentation](/docs/) to learn more! diff --git a/Website/src/blog/functional-error-handling.md b/Website/src/blog/functional-error-handling.md new file mode 100644 index 00000000..6ea0de2e --- /dev/null +++ b/Website/src/blog/functional-error-handling.md @@ -0,0 +1,61 @@ +--- +layout: layouts/blog.njk +title: Functional Error Handling in C# with RestClient.Net +date: 2026-01-15 +author: Christian Findlay +tags: posts +excerpt: Learn how discriminated unions and exhaustiveness checking eliminate runtime crashes in your REST calls. +--- + +# Functional Error Handling in C# + +Traditional C# error handling relies heavily on exceptions. But exceptions have problems: + +1. **They're invisible** - Nothing in the type signature tells you what might throw +2. **They're easy to forget** - Miss a catch block and your app crashes +3. **They're expensive** - Stack traces have performance overhead + +## A Better Way: Result Types + +RestClient.Net uses discriminated unions to represent all possible outcomes: + +```csharp +// The type tells you everything that can happen +Result> result = await httpClient.GetAsync(...); +``` + +## Pattern Matching + +C#'s pattern matching makes working with results elegant: + +```csharp +var message = result switch +{ + OkPost(var post) => $"Got post: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"API error {status}: {err.Message}", + ErrorPost(ExceptionErrorPost(var ex)) => $"Network error: {ex.Message}", +}; +``` + +## Exhaustiveness = Safety + +The Exhaustion analyzer turns forgotten cases into compile errors. This is the key insight: **runtime crashes become compile-time errors**. + +```csharp +// This won't compile! +var message = result switch +{ + OkPost(var post) => "Success", + // Missing ErrorPost cases - COMPILE ERROR +}; +``` + +## Why This Matters + +When every possible outcome is explicit in the type system: + +- New team members can't accidentally skip error handling +- Refactoring is safe - the compiler catches missing cases +- Code review focuses on logic, not "did they handle errors?" + +Try RestClient.Net today and experience the safety of functional error handling in C#. diff --git a/Website/src/blog/introducing-restclient.md b/Website/src/blog/introducing-restclient.md new file mode 100644 index 00000000..0bc2cc50 --- /dev/null +++ b/Website/src/blog/introducing-restclient.md @@ -0,0 +1,49 @@ +--- +layout: layouts/blog.njk +title: Introducing RestClient.Net - Type-Safe REST Calls in C# +date: 2024-01-15 +author: Christian Findlay +excerpt: Learn how RestClient.Net brings functional programming patterns to HTTP calls with discriminated unions and exhaustiveness checking. +tags: posts +--- + +# Introducing RestClient.Net + +RestClient.Net is a modern, type-safe REST client for C# that brings functional programming patterns to HTTP communication. + +## Why Another REST Client? + +Traditional HTTP clients in .NET have a problem: they throw exceptions. This forces developers into try/catch blocks and makes error handling inconsistent and error-prone. + +RestClient.Net takes a different approach. Instead of throwing exceptions, every HTTP call returns a `Result` type that must be explicitly handled. + +## The Power of Discriminated Unions + +```csharp +// Make a GET request +var result = await httpClient.GetAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializePost, + deserializeError: DeserializeError +); + +// Pattern match - compiler enforces you handle ALL cases +var output = result switch +{ + OkPost(var post) => $"Success: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"API Error: {status}", + ErrorPost(ExceptionErrorPost(var ex)) => $"Exception: {ex.Message}", +}; +``` + +## Exhaustiveness Checking + +With the [Exhaustion analyzer](https://www.nuget.org/packages/Exhaustion), your code won't compile if you miss a case. No more runtime surprises. + +## Get Started + +```bash +dotnet add package RestClient.Net +``` + +Check out the [documentation](/docs/) to learn more! diff --git a/Website/src/blog/mcp-server-tutorial.md b/Website/src/blog/mcp-server-tutorial.md new file mode 100644 index 00000000..72085742 --- /dev/null +++ b/Website/src/blog/mcp-server-tutorial.md @@ -0,0 +1,340 @@ +--- +layout: layouts/blog.njk +title: Building AI-Powered Applications with MCP Servers +date: 2026-01-10 +author: Christian Findlay +excerpt: Learn how to turn your REST APIs into AI-accessible tools using RestClient.Net's MCP Server generator. +tags: posts +--- + +# Building AI-Powered Applications with MCP Servers + +The Model Context Protocol (MCP) is revolutionizing how AI assistants interact with external systems. With RestClient.Net, you can generate MCP servers from your existing OpenAPI specifications, enabling Claude Code to call your APIs directly. + +## What is MCP? + +MCP (Model Context Protocol) is a standardized way for AI assistants to: + +- Call external APIs as "tools" +- Access data sources securely +- Perform actions on behalf of users + +Think of it as giving Claude Code hands to interact with the real world. + +## The Power of Code Generation + +Instead of manually writing MCP server code, RestClient.Net generates it from your OpenAPI spec: + +``` +OpenAPI Spec → RestClient.Net Generator → Type-Safe C# Client + ↓ +MCP Generator → MCP Server → Claude Code Tools +``` + +Every change to your API spec automatically updates your AI tools. + +## Real-World Example: E-Commerce API + +Let's build an MCP server for a simple e-commerce API. + +### The OpenAPI Spec + +```yaml +openapi: 3.0.3 +info: + title: E-Commerce API + version: 1.0.0 +paths: + /products/search: + get: + operationId: searchProducts + summary: Search for products by keyword + parameters: + - name: query + in: query + required: true + schema: + type: string + responses: + '200': + description: Search results + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Product' + + /products/{id}: + get: + operationId: getProduct + summary: Get product details + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Product details + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + + /orders: + post: + operationId: createOrder + summary: Place a new order + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateOrderRequest' + responses: + '201': + description: Order created + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + +components: + schemas: + Product: + type: object + properties: + id: + type: string + name: + type: string + price: + type: number + inStock: + type: boolean + + CreateOrderRequest: + type: object + required: [productId, quantity] + properties: + productId: + type: string + quantity: + type: integer + + Order: + type: object + properties: + id: + type: string + status: + type: string + total: + type: number +``` + +### Generate the Client + +```bash +restclient-gen \ + --input ecommerce.yaml \ + --output Generated \ + --namespace ECommerce.Client +``` + +### Generate the MCP Server + +```bash +restclient-mcp \ + --openapi ecommerce.yaml \ + --output McpServer \ + --namespace ECommerce.Mcp \ + --server-name ECommerceServer \ + --client-namespace ECommerce.Client +``` + +### Configure Claude Code + +Add to `.claude/mcp.json`: + +```json +{ + "mcpServers": { + "ecommerce": { + "command": "dotnet", + "args": ["run", "--project", "ECommerce.McpServer"], + "env": { + "API_BASE_URL": "https://api.mystore.com", + "API_KEY": "your-api-key" + } + } + } +} +``` + +## Claude Code in Action + +Now Claude can interact with your API naturally: + +**User:** "Find me a laptop under $1000" + +**Claude:** *calls search_products with query="laptop"* + +"I found 3 laptops under $1000: +1. ThinkPad E14 - $799 +2. HP Pavilion - $649 +3. Acer Aspire 5 - $549 + +Would you like more details on any of these?" + +**User:** "Tell me more about the ThinkPad" + +**Claude:** *calls get_product with id="thinkpad-e14"* + +"The ThinkPad E14 features: +- 14" FHD display +- Intel Core i5-1235U +- 16GB RAM +- 512GB SSD +- Price: $799 +- In Stock: Yes + +Would you like to order one?" + +**User:** "Yes, order 1" + +**Claude:** *calls create_order with productId="thinkpad-e14", quantity=1* + +"Order placed successfully! Your order #12345 for the ThinkPad E14 has been confirmed. Total: $799." + +## Security Considerations + +### Authentication + +Always authenticate API calls: + +```csharp +services.AddHttpClient("api", client => +{ + client.BaseAddress = new Uri( + Environment.GetEnvironmentVariable("API_BASE_URL")!); +}) +.AddHttpMessageHandler(); + +public class ApiKeyHandler : DelegatingHandler +{ + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var apiKey = Environment.GetEnvironmentVariable("API_KEY"); + request.Headers.Add("X-API-Key", apiKey); + return base.SendAsync(request, cancellationToken); + } +} +``` + +### Rate Limiting + +Protect your API from excessive calls: + +```csharp +services.AddHttpClient("api") + .AddPolicyHandler(Policy.RateLimitAsync( + numberOfExecutions: 10, + perTimeSpan: TimeSpan.FromSeconds(1) + )); +``` + +### Input Validation + +The generated tools validate inputs, but add extra validation for sensitive operations: + +```csharp +public async Task ExecuteAsync(JsonElement arguments, CancellationToken ct) +{ + var quantity = arguments.GetProperty("quantity").GetInt32(); + + if (quantity > 100) + { + return ToolResult.Error("Maximum order quantity is 100"); + } + + // Proceed with order +} +``` + +## Advanced: Custom Tool Descriptions + +Enhance tool descriptions for better AI understanding: + +```yaml +paths: + /products/search: + get: + operationId: searchProducts + summary: Search for products by keyword + description: | + Searches the product catalog using full-text search. + Returns up to 20 results sorted by relevance. + Use specific keywords for better results (e.g., "laptop 16GB RAM" instead of just "computer"). +``` + +## Debugging MCP Servers + +### Local Testing + +Run the server locally: + +```bash +dotnet run --project ECommerce.McpServer +``` + +Use the MCP inspector: + +```bash +npx @anthropic-ai/mcp-inspector +``` + +### Logging + +Add logging to track Claude's requests: + +```csharp +public async Task ExecuteAsync(JsonElement arguments, CancellationToken ct) +{ + _logger.LogInformation( + "Tool {ToolName} called with arguments: {Arguments}", + Name, + arguments.GetRawText() + ); + + // Execute tool... + + _logger.LogInformation( + "Tool {ToolName} completed successfully", + Name + ); + + return result; +} +``` + +## Conclusion + +MCP servers powered by RestClient.Net let you: + +1. **Generate AI tools from OpenAPI specs** - No manual coding +2. **Maintain type safety** - Result types and exhaustiveness checking +3. **Keep tools in sync** - Regenerate when specs change +4. **Build powerful AI applications** - Claude can interact with any REST API + +The future of AI is about giving models access to real-world systems. With RestClient.Net and MCP, you're ready to build that future. + +## Next Steps + +- Read the [MCP Server documentation](/docs/mcp/) +- Explore the [OpenAPI Generator](/docs/openapi/) +- Check out the [API Reference](/api/) diff --git a/Website/src/blog/migration-from-httpclient.md b/Website/src/blog/migration-from-httpclient.md new file mode 100644 index 00000000..8aa71646 --- /dev/null +++ b/Website/src/blog/migration-from-httpclient.md @@ -0,0 +1,293 @@ +--- +layout: layouts/blog.njk +title: Migrating from Traditional HttpClient to RestClient.Net +date: 2026-01-18 +author: Christian Findlay +excerpt: Step-by-step guide to migrating your existing HttpClient code to RestClient.Net with functional error handling. +tags: posts +--- + +# Migrating from Traditional HttpClient to RestClient.Net + +If you have existing code using `HttpClient` with traditional try/catch error handling, this guide will help you migrate to RestClient.Net's functional approach. + +## Before: Traditional HttpClient + +Here's typical HttpClient code you might have today: + +```csharp +public class UserService +{ + private readonly HttpClient _httpClient; + + public UserService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public async Task GetUserAsync(string userId) + { + try + { + var response = await _httpClient.GetAsync($"/users/{userId}"); + + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadFromJsonAsync(); + } + + // How do we handle errors? Return null? Throw? + return null; + } + catch (HttpRequestException) + { + // Network error - return null? throw? log? + return null; + } + catch (JsonException) + { + // Deserialization error + return null; + } + } +} +``` + +Problems with this approach: + +1. **Null return hides errors** - Callers don't know if user wasn't found or network failed +2. **Error handling is scattered** - Multiple catch blocks, inconsistent handling +3. **Easy to miss exceptions** - What about `TaskCanceledException`? +4. **Response status lost** - We can't tell 404 from 500 + +## After: RestClient.Net + +Here's the same service with RestClient.Net: + +```csharp +public class UserService +{ + private readonly HttpClient _httpClient; + + public UserService(HttpClient httpClient) + { + _httpClient = httpClient; + } + + public Task>> GetUserAsync( + string userId, + CancellationToken ct = default) => + _httpClient.GetAsync( + url: $"/users/{userId}".ToAbsoluteUrl(), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Null response"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? new ApiError("Unknown error"), + cancellationToken: ct + ); +} +``` + +Benefits: + +1. **Return type tells the full story** - Success or error, with details +2. **No exceptions to catch** - Everything is in the return type +3. **Compiler enforces handling** - With Exhaustion analyzer +4. **Status codes preserved** - In `ResponseError` + +## Step-by-Step Migration + +### Step 1: Install RestClient.Net + +```bash +dotnet add package RestClient.Net +``` + +### Step 2: Define Error Types + +Create a model for your API errors: + +```csharp +// Models/ApiError.cs +public sealed record ApiError(string Message, string? Code = null); +``` + +### Step 3: Create Type Aliases + +Add to `GlobalUsings.cs`: + +```csharp +// For User +global using OkUser = Outcome.Result> + .Ok>; +global using ErrorUser = Outcome.Result> + .Error>; +global using ResponseErrorUser = Outcome.HttpError.ErrorResponseError; +global using ExceptionErrorUser = Outcome.HttpError.ExceptionError; +``` + +### Step 4: Update Service Methods + +Change from: + +```csharp +public async Task GetUserAsync(string userId) +``` + +To: + +```csharp +public Task>> GetUserAsync( + string userId, + CancellationToken ct = default) +``` + +### Step 5: Update Callers + +Before: + +```csharp +var user = await userService.GetUserAsync("123"); +if (user == null) +{ + // Handle error... somehow + return; +} +Console.WriteLine(user.Name); +``` + +After: + +```csharp +var result = await userService.GetUserAsync("123"); + +var message = result switch +{ + OkUser(var user) => user.Name, + ErrorUser(ResponseErrorUser(_, HttpStatusCode.NotFound, _)) => "User not found", + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"Error {status}: {err.Message}", + ErrorUser(ExceptionErrorUser(var ex)) => $"Network error: {ex.Message}", +}; +``` + +## Common Patterns + +### Returning Early on Error + +Before: + +```csharp +var user = await GetUserAsync(userId); +if (user == null) return null; + +var orders = await GetOrdersAsync(user.Id); +if (orders == null) return null; + +return new Dashboard(user, orders); +``` + +After: + +```csharp +var userResult = await GetUserAsync(userId); +var ordersResult = await GetOrdersAsync(userId); + +return (userResult, ordersResult) switch +{ + (OkUser(var user), OkOrders(var orders)) => new Dashboard(user, orders), + (ErrorUser(var e), _) => throw new Exception($"User error: {e}"), + (_, ErrorOrders(var e)) => throw new Exception($"Orders error: {e}"), +}; +``` + +### Converting to Nullable (Escape Hatch) + +If you need to maintain the old interface temporarily: + +```csharp +public async Task GetUserOrNullAsync(string userId) +{ + var result = await GetUserAsync(userId); + return result switch + { + OkUser(var user) => user, + _ => null, + }; +} +``` + +### Logging Errors + +```csharp +var result = await GetUserAsync(userId); + +var user = result switch +{ + OkUser(var u) => u, + ErrorUser(ResponseErrorUser(var err, var status, _)) => + { + _logger.LogWarning("API error {Status}: {Message}", status, err.Message); + return null; + }, + ErrorUser(ExceptionErrorUser(var ex)) => + { + _logger.LogError(ex, "Network error getting user {UserId}", userId); + return null; + }, +}; +``` + +## Gradual Migration + +You don't have to migrate everything at once: + +1. **Start with new code** - Use RestClient.Net for new endpoints +2. **Migrate high-traffic paths** - Critical code benefits most +3. **Create adapters** - Wrap old code if needed +4. **Update tests** - Add tests for all cases + +## Testing After Migration + +Ensure you test all cases: + +```csharp +[Fact] +public async Task GetUser_ReturnsUser_WhenFound() +{ + // Arrange - setup mock to return 200 + var result = await service.GetUserAsync("123"); + Assert.IsType(result); +} + +[Fact] +public async Task GetUser_ReturnsResponseError_WhenNotFound() +{ + // Arrange - setup mock to return 404 + var result = await service.GetUserAsync("unknown"); + Assert.IsType(result); + var error = ((ErrorUser)result).Value; + Assert.IsType(error); +} + +[Fact] +public async Task GetUser_ReturnsExceptionError_WhenNetworkFails() +{ + // Arrange - setup mock to throw + var result = await service.GetUserAsync("123"); + var error = ((ErrorUser)result).Value; + Assert.IsType(error); +} +``` + +## Summary + +Migrating to RestClient.Net provides: + +- **Explicit error handling** - The type system enforces it +- **Better error information** - Status codes, response bodies, exceptions +- **Compile-time safety** - With Exhaustion analyzer +- **Cleaner code** - No scattered try/catch blocks + +Start small, migrate gradually, and enjoy the safety of functional error handling. diff --git a/Website/src/blog/openapi-generator-released.md b/Website/src/blog/openapi-generator-released.md new file mode 100644 index 00000000..be74ecaa --- /dev/null +++ b/Website/src/blog/openapi-generator-released.md @@ -0,0 +1,59 @@ +--- +layout: layouts/blog.njk +title: OpenAPI Generator Now Available +date: 2024-02-20 +author: Christian Findlay +excerpt: Generate type-safe C# clients from OpenAPI 3.x specifications with automatic Result type aliases. +tags: posts +--- + +# OpenAPI Generator Now Available + +We're excited to announce the release of RestClient.Net.OpenApiGenerator - a tool that generates type-safe C# clients from OpenAPI 3.x specifications. + +## Why Generate Clients? + +Writing HTTP client code by hand is tedious and error-prone. With OpenAPI specifications becoming the standard for API documentation, it makes sense to generate clients automatically. + +## How It Works + +```bash +# Install the generator +dotnet add package RestClient.Net.OpenApiGenerator + +# Generate from your OpenAPI spec +dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \ + -u api.yaml \ + -o Generated \ + -n YourApi.Generated +``` + +## Generated Code Example + +The generator creates extension methods for `HttpClient` with proper Result types: + +```csharp +using YourApi.Generated; + +var httpClient = factory.CreateClient(); + +// Type-safe, exhaustive error handling +var user = await httpClient.GetUserById("123", ct); + +switch (user) +{ + case OkUser(var success): + Console.WriteLine($"Found: {success.Name}"); + break; + case ErrorUser(ResponseErrorUser(var err, var status, _)): + Console.WriteLine($"API Error: {status}"); + break; + case ErrorUser(ExceptionErrorUser(var ex)): + Console.WriteLine($"Exception: {ex.Message}"); + break; +} +``` + +## Learn More + +Read the full [OpenAPI Generator documentation](/docs/openapi/) to get started. diff --git a/Website/src/blog/why-discriminated-unions.md b/Website/src/blog/why-discriminated-unions.md new file mode 100644 index 00000000..23ee597c --- /dev/null +++ b/Website/src/blog/why-discriminated-unions.md @@ -0,0 +1,254 @@ +--- +layout: layouts/blog.njk +title: Why Discriminated Unions Are the Future of Error Handling in C# +date: 2025-12-20 +author: Christian Findlay +excerpt: Explore how discriminated unions and pattern matching transform error handling from an afterthought into a first-class language feature. +tags: posts +--- + +# Why Discriminated Unions Are the Future of Error Handling in C# + +Error handling in most C# codebases is a mess. We pretend exceptions are exceptional, scatter try-catch blocks throughout our code, and hope we've caught everything. There's a better way. + +## The Exception Problem + +Exceptions have served us well, but they have fundamental issues: + +### 1. They're Invisible + +Look at this method signature: + +```csharp +public async Task GetUserAsync(string userId) +``` + +What can go wrong? The signature doesn't tell you: +- Network failures? +- User not found (404)? +- Authentication expired (401)? +- Server errors (500)? +- JSON parsing errors? +- Timeout? + +You have to read the documentation (if it exists) or the implementation to find out. + +### 2. They're Easy to Forget + +```csharp +var user = await userService.GetUserAsync("123"); +// If this throws, we crash +Console.WriteLine(user.Name); +``` + +The compiler happily accepts this code. No warning. No error. Just a runtime crash waiting to happen. + +### 3. They Encourage Bad Patterns + +When exceptions are your only error mechanism, you end up with: + +```csharp +try +{ + var user = await GetUserAsync(userId); + var orders = await GetOrdersAsync(user.Id); + var recommendations = await GetRecommendationsAsync(user.Id); + return new Dashboard(user, orders, recommendations); +} +catch (HttpRequestException ex) +{ + // Which call failed? We don't know! + return null; +} +catch (JsonException ex) +{ + // Deserialization failed... somewhere + return null; +} +catch (Exception ex) +{ + // The "catch all" that catches nothing useful + return null; +} +``` + +## Enter Discriminated Unions + +A discriminated union is a type that can be one of several distinct cases. In RestClient.Net: + +```csharp +Result> result = await GetUserAsync(userId); +``` + +This type says: "I am either a successful User OR an error. I cannot be null. I cannot be something else." + +### The Result Type + +```csharp +public abstract record Result +{ + public sealed record Ok(TSuccess Value) : Result; + public sealed record Error(TError Value) : Result; +} +``` + +Simple. Elegant. Complete. + +### The HttpError Type + +For HTTP operations, we further distinguish error types: + +```csharp +public abstract record HttpError +{ + // Server returned an error response + public sealed record ResponseError( + TError Error, + HttpStatusCode StatusCode, + HttpResponseMessage Response + ) : HttpError; + + // Exception occurred (network, timeout, etc.) + public sealed record ExceptionError( + Exception Exception + ) : HttpError; +} +``` + +Now the type tells you exactly what can happen: +- Success with a User +- Error response from the server (with status code and body) +- Exception during the request (with the exception) + +## Pattern Matching: The Key + +C#'s pattern matching makes working with discriminated unions elegant: + +```csharp +var message = result switch +{ + OkUser(var user) => $"Welcome, {user.Name}!", + ErrorUser(ResponseErrorUser(_, HttpStatusCode.NotFound, _)) => "User not found", + ErrorUser(ResponseErrorUser(_, HttpStatusCode.Unauthorized, _)) => "Please log in", + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"Error {status}: {err.Message}", + ErrorUser(ExceptionErrorUser(var ex)) => $"Network error: {ex.Message}", +}; +``` + +Every case is explicit. The compiler knows all possibilities. + +## Exhaustiveness: The Game Changer + +Here's where it gets powerful. With the Exhaustion analyzer, this code won't compile: + +```csharp +var message = result switch +{ + OkUser(var user) => user.Name, + ErrorUser(ResponseErrorUser(var err, _, _)) => err.Message, + // COMPILE ERROR: Missing ExceptionErrorUser case! +}; +``` + +``` +error EXHAUSTION001: Switch on Result is not exhaustive; +Missing: Error> with ExceptionError +``` + +**Runtime crashes become compile-time errors.** + +## Real-World Benefits + +### 1. Self-Documenting Code + +The return type tells you everything: + +```csharp +// This method can return a User, a 404 error, or a network exception +public Task>> GetUserAsync(string id) +``` + +### 2. Fearless Refactoring + +Add a new error case? The compiler finds every place that needs updating: + +```csharp +// Added a new error type +public abstract record HttpError +{ + public sealed record ResponseError(...) : HttpError; + public sealed record ExceptionError(...) : HttpError; + public sealed record TimeoutError(...) : HttpError; // NEW! +} +``` + +Every switch statement that handles `HttpError` now shows a compile error until you handle `TimeoutError`. + +### 3. Composable Operations + +Chain operations that might fail: + +```csharp +var dashboard = await GetUserAsync(userId) + .FlatMapAsync(user => GetOrdersAsync(user.Id)) + .MapAsync(orders => new Dashboard(orders)); +``` + +If any step fails, the error propagates. No try-catch needed. + +### 4. Better Testing + +Test each case explicitly: + +```csharp +[Fact] +public async Task GetUser_ReturnsResponseError_WhenNotFound() +{ + var result = await service.GetUserAsync("unknown"); + + var error = Assert.IsType(result); + var responseError = Assert.IsType(error.Value); + Assert.Equal(HttpStatusCode.NotFound, responseError.StatusCode); +} +``` + +### 5. Cleaner Code Reviews + +Reviewers don't need to ask "did you handle errors?" The compiler already verified it. + +## The Path Forward + +C# is evolving toward better support for discriminated unions. But you don't have to wait: + +1. **Use RestClient.Net** for HTTP operations +2. **Install the Exhaustion analyzer** for compile-time checking +3. **Create your own discriminated unions** for domain logic + +Example domain union: + +```csharp +public abstract record PaymentResult +{ + public sealed record Success(string TransactionId) : PaymentResult; + public sealed record InsufficientFunds(decimal Available, decimal Required) : PaymentResult; + public sealed record CardDeclined(string Reason) : PaymentResult; + public sealed record Timeout(TimeSpan Duration) : PaymentResult; +} +``` + +Now payment handling is explicit, exhaustive, and documented in the type system. + +## Conclusion + +Discriminated unions transform error handling from: +- **Invisible** to **explicit** +- **Optional** to **required** +- **Runtime crashes** to **compile-time errors** + +It's not just a different syntax. It's a fundamentally safer approach to building software. + +Try RestClient.Net today and experience the future of error handling in C#. + +--- + +*Related: [Functional Error Handling in C#](/blog/functional-error-handling/) | [Getting Started](/docs/)* diff --git a/Website/src/docs/advanced-usage.md b/Website/src/docs/advanced-usage.md new file mode 100644 index 00000000..900c52d1 --- /dev/null +++ b/Website/src/docs/advanced-usage.md @@ -0,0 +1,407 @@ +--- +layout: layouts/docs.njk +title: Advanced Usage +description: Advanced RestClient.Net patterns including retry policies, custom middleware, authentication handlers, and integration with dependency injection. +keywords: RestClient.Net advanced, retry policy, IHttpClientFactory, authentication handler, middleware +eleventyNavigation: + key: Advanced Usage + order: 5 +faq: + - question: How do I add retry logic to RestClient.Net? + answer: Use Polly with IHttpClientFactory to add retry policies. Configure retry strategies for transient errors like network timeouts or 5xx responses. + - question: How do I use RestClient.Net with dependency injection? + answer: Register your services with IHttpClientFactory, configure named or typed clients, and inject them into your services. + - question: How do I handle authentication with RestClient.Net? + answer: Use DelegatingHandler to create an authentication handler that adds tokens to requests. Combine with IHttpClientFactory for token refresh. +--- + +# Advanced Usage + +This guide covers advanced patterns for production use of RestClient.Net. + +## Using IHttpClientFactory + +Always use `IHttpClientFactory` in production to avoid socket exhaustion: + +```csharp +// Program.cs +builder.Services.AddHttpClient("api", client => +{ + client.BaseAddress = new Uri("https://api.example.com"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// In your service +public class UserService(IHttpClientFactory httpClientFactory) +{ + public async Task>> GetUserAsync( + string userId, + CancellationToken ct = default) + { + var client = httpClientFactory.CreateClient("api"); + + return await client.GetAsync( + url: $"/users/{userId}".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error, + cancellationToken: ct + ); + } +} +``` + +## Typed Clients + +Create strongly-typed clients for better organization: + +```csharp +// UserApiClient.cs +public class UserApiClient(HttpClient httpClient) +{ + public Task>> GetUserAsync( + string userId, + CancellationToken ct = default) => + httpClient.GetAsync( + url: $"/users/{userId}".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error, + cancellationToken: ct + ); + + public Task>> CreateUserAsync( + CreateUserRequest request, + CancellationToken ct = default) => + httpClient.PostAsync( + url: "/users".ToAbsoluteUrl(), + body: request, + serializeRequest: body => JsonContent.Create(body), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error, + cancellationToken: ct + ); +} + +// Program.cs +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://api.example.com"); +}); +``` + +## Retry Policies with Polly + +Add retry logic for transient failures: + +```csharp +using Microsoft.Extensions.Http.Resilience; + +// Program.cs +builder.Services.AddHttpClient("api") + .AddStandardResilienceHandler(options => + { + options.Retry.MaxRetryAttempts = 3; + options.Retry.Delay = TimeSpan.FromMilliseconds(500); + options.Retry.UseJitter = true; + options.Retry.ShouldHandle = args => ValueTask.FromResult( + args.Outcome.Exception is not null || + args.Outcome.Result?.StatusCode >= HttpStatusCode.InternalServerError + ); + }); +``` + +### Custom Retry Strategy + +For more control: + +```csharp +builder.Services.AddHttpClient("api") + .AddPolicyHandler( + HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests) + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: (retryAttempt, response, context) => + { + // Check for Retry-After header + if (response.Result?.Headers.RetryAfter?.Delta is { } delta) + { + return delta; + } + // Exponential backoff + return TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)); + }, + onRetryAsync: (outcome, timespan, retryAttempt, context) => + { + Console.WriteLine($"Retry {retryAttempt} after {timespan}"); + return Task.CompletedTask; + } + ) + ); +``` + +## Authentication Handler + +Create a delegating handler for authentication: + +```csharp +public class AuthenticationHandler(ITokenService tokenService) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var token = await tokenService.GetAccessTokenAsync(cancellationToken); + + request.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", token); + + return await base.SendAsync(request, cancellationToken); + } +} + +// Program.cs +builder.Services.AddTransient(); +builder.Services.AddHttpClient("api") + .AddHttpMessageHandler(); +``` + +### Token Refresh Handler + +Automatically refresh expired tokens: + +```csharp +public class TokenRefreshHandler( + ITokenService tokenService, + ILogger logger) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var token = await tokenService.GetAccessTokenAsync(cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await base.SendAsync(request, cancellationToken); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + logger.LogInformation("Token expired, refreshing..."); + + token = await tokenService.RefreshTokenAsync(cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + response = await base.SendAsync(request, cancellationToken); + } + + return response; + } +} +``` + +## Request/Response Logging + +Log all HTTP traffic: + +```csharp +public class LoggingHandler(ILogger logger) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var requestId = Guid.NewGuid().ToString("N")[..8]; + + logger.LogInformation( + "[{RequestId}] {Method} {Uri}", + requestId, + request.Method, + request.RequestUri + ); + + var stopwatch = Stopwatch.StartNew(); + var response = await base.SendAsync(request, cancellationToken); + stopwatch.Stop(); + + logger.LogInformation( + "[{RequestId}] {StatusCode} in {ElapsedMs}ms", + requestId, + (int)response.StatusCode, + stopwatch.ElapsedMilliseconds + ); + + return response; + } +} +``` + +## Circuit Breaker + +Prevent cascading failures: + +```csharp +builder.Services.AddHttpClient("api") + .AddPolicyHandler( + HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync( + handledEventsAllowedBeforeBreaking: 5, + durationOfBreak: TimeSpan.FromSeconds(30), + onBreak: (result, duration) => + { + Console.WriteLine($"Circuit broken for {duration}"); + }, + onReset: () => + { + Console.WriteLine("Circuit reset"); + } + ) + ); +``` + +## Custom Serialization + +Use different serializers: + +### System.Text.Json with Options + +```csharp +public static class Deserializers +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static async Task Json(HttpContent content, CancellationToken ct) + where T : class => + await content.ReadFromJsonAsync(Options, ct) + ?? throw new InvalidOperationException($"Failed to deserialize {typeof(T).Name}"); +} +``` + +### Newtonsoft.Json + +```csharp +public static async Task NewtonsoftJson(HttpContent content, CancellationToken ct) +{ + var json = await content.ReadAsStringAsync(ct); + return JsonConvert.DeserializeObject(json) + ?? throw new InvalidOperationException($"Failed to deserialize {typeof(T).Name}"); +} +``` + +## Caching Responses + +Cache successful responses: + +```csharp +public class CachingService( + IMemoryCache cache, + IHttpClientFactory httpClientFactory) +{ + public async Task>> GetUserAsync( + string userId, + CancellationToken ct = default) + { + var cacheKey = $"user:{userId}"; + + if (cache.TryGetValue(cacheKey, out var cached)) + { + return new Result>.Ok(cached); + } + + var client = httpClientFactory.CreateClient("api"); + + var result = await client.GetAsync( + url: $"/users/{userId}".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error, + cancellationToken: ct + ); + + if (result is Result>.Ok(var user)) + { + cache.Set(cacheKey, user, TimeSpan.FromMinutes(5)); + } + + return result; + } +} +``` + +## Request Correlation + +Add correlation IDs for distributed tracing: + +```csharp +public class CorrelationHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler +{ + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var correlationId = httpContextAccessor.HttpContext? + .Request.Headers["X-Correlation-ID"].FirstOrDefault() + ?? Guid.NewGuid().ToString(); + + request.Headers.Add("X-Correlation-ID", correlationId); + + return base.SendAsync(request, cancellationToken); + } +} +``` + +## Testing + +### Mock HttpClient + +```csharp +public class UserServiceTests +{ + [Fact] + public async Task GetUser_ReturnsUser_WhenFound() + { + // Arrange + var handler = new MockHttpMessageHandler(request => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new User("1", "John")) + }); + + var client = new HttpClient(handler) + { + BaseAddress = new Uri("https://api.example.com") + }; + + var service = new UserService(client); + + // Act + var result = await service.GetUserAsync("1"); + + // Assert + Assert.IsType(result); + } +} +``` + +## Best Practices Summary + +1. **Always use IHttpClientFactory** in production +2. **Configure timeouts** explicitly +3. **Add retry policies** for transient errors +4. **Use circuit breakers** to prevent cascading failures +5. **Implement authentication handlers** for token management +6. **Log requests and responses** for debugging +7. **Add correlation IDs** for distributed tracing +8. **Cache expensive operations** when appropriate +9. **Test with mock handlers** for reliability + +## Next Steps + +- [OpenAPI Generator](/docs/openapi/) - Generate clients automatically +- [MCP Server](/docs/mcp/) - Claude Code integration +- [API Reference](/api/) - Complete documentation diff --git a/Website/src/docs/api/delegates.md b/Website/src/docs/api/delegates.md new file mode 100644 index 00000000..ec4d07a5 --- /dev/null +++ b/Website/src/docs/api/delegates.md @@ -0,0 +1,346 @@ +--- +layout: layouts/docs.njk +title: Delegates +eleventyNavigation: + key: Delegates + parent: API Reference + order: 3 +--- + +# Delegates + +RestClient.Net uses delegate types to define functions for serialization, deserialization, and request building. + +**Namespace:** `RestClient.Net` + +## HttpRequestParts + +A record struct that contains all parts of an HTTP request. + +```csharp +public readonly record struct HttpRequestParts( + RelativeUrl RelativeUrl, + HttpContent? Body, + IReadOnlyDictionary? Headers +); +``` + +### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `RelativeUrl` | `RelativeUrl` | The relative URL path for the request | +| `Body` | `HttpContent?` | Optional HTTP content for the request body | +| `Headers` | `IReadOnlyDictionary?` | Optional headers to include | + +### Example + +```csharp +var parts = new HttpRequestParts( + RelativeUrl: "/users/123".ToRelativeUrl(), + Body: new StringContent("{\"name\":\"John\"}", Encoding.UTF8, "application/json"), + Headers: new Dictionary { ["X-Custom-Header"] = "value" } +); +``` + +--- + +## BuildRequest<TParam> + +A delegate that builds HTTP request parts from a typed parameter. + +```csharp +public delegate HttpRequestParts BuildRequest(TParam argument); +``` + +### Type Parameters + +| Parameter | Description | +|-----------|-------------| +| `TParam` | The type of parameter used to build the request | + +### Example + +```csharp +BuildRequest buildUserRequest = userId => new HttpRequestParts( + RelativeUrl: $"/users/{userId}".ToRelativeUrl(), + Body: null, + Headers: null +); + +var parts = buildUserRequest(42); +// parts.RelativeUrl == "/users/42" +``` + +--- + +## Deserialize<T> + +A delegate for asynchronously deserializing an HTTP response into a typed object. + +```csharp +public delegate Task Deserialize( + HttpResponseMessage httpResponseMessage, + CancellationToken cancellationToken +); +``` + +### Type Parameters + +| Parameter | Description | +|-----------|-------------| +| `T` | The type to deserialize the response content into | + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `httpResponseMessage` | `HttpResponseMessage` | The full HTTP response message | +| `cancellationToken` | `CancellationToken` | Token to cancel the operation | + +### Example + +```csharp +// JSON deserializer +Deserialize deserializeUser = async (response, ct) => +{ + var stream = await response.Content.ReadAsStreamAsync(ct); + return await JsonSerializer.DeserializeAsync(stream, cancellationToken: ct) + ?? throw new InvalidOperationException("Deserialization returned null"); +}; + +// String deserializer +Deserialize deserializeString = async (response, ct) => + await response.Content.ReadAsStringAsync(ct); + +// Header-based deserializer +Deserialize<(string Content, string? ETag)> deserializeWithETag = async (response, ct) => +{ + var content = await response.Content.ReadAsStringAsync(ct); + var etag = response.Headers.ETag?.Tag; + return (content, etag); +}; +``` + +--- + +## Serialize<T> + +A delegate for asynchronously serializing a typed object into HTTP content. + +```csharp +public delegate Task Serialize( + T body, + CancellationToken cancellationToken +); +``` + +### Type Parameters + +| Parameter | Description | +|-----------|-------------| +| `T` | The type of object to serialize | + +### Example + +```csharp +Serialize serializeUser = (user, ct) => +{ + var json = JsonSerializer.Serialize(user); + return Task.FromResult( + new StringContent(json, Encoding.UTF8, "application/json") + ); +}; +``` + +--- + +## GetRelativeUrl<TParam> + +A delegate that constructs a relative URL from a typed parameter. + +```csharp +public delegate RelativeUrl GetRelativeUrl(TParam argument); +``` + +### Example + +```csharp +GetRelativeUrl<(string collection, int id)> getResourceUrl = + param => $"/{param.collection}/{param.id}".ToRelativeUrl(); + +var url = getResourceUrl(("users", 42)); +// url == "/users/42" +``` + +--- + +## HttpAction + +A delegate that performs the core HTTP send operation. + +```csharp +public delegate Task HttpAction( + HttpClient httpClient, + HttpRequestMessage requestMessage, + CancellationToken cancellationToken +); +``` + +### Purpose + +This delegate allows you to override the default HTTP send behavior. Use cases include: +- Adding custom logging +- Implementing retry logic +- Modifying requests before sending +- Capturing metrics + +### Example + +```csharp +HttpAction customAction = async (client, request, ct) => +{ + // Log the request + Console.WriteLine($"Sending {request.Method} to {request.RequestUri}"); + + var stopwatch = Stopwatch.StartNew(); + var response = await client.SendAsync(request, ct); + stopwatch.Stop(); + + // Log the response time + Console.WriteLine($"Response: {response.StatusCode} in {stopwatch.ElapsedMilliseconds}ms"); + + return response; +}; + +var result = await httpClient.SendAsync( + url: "https://api.example.com/users".ToAbsoluteUrl(), + httpMethod: HttpMethod.Get, + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson, + headers: null, + requestBody: null, + httpOperation: customAction +); +``` + +--- + +## Request Delegates + +These delegates represent pre-configured HTTP request functions. + +### GetAsync<TSuccess, TError, TParam> + +```csharp +public delegate Task>> GetAsync( + HttpClient httpClient, + TParam parameters, + CancellationToken cancellationToken = default +); +``` + +### PostAsync<TSuccess, TError, TRequest> + +```csharp +public delegate Task>> PostAsync( + HttpClient httpClient, + TRequest requestBody, + CancellationToken cancellationToken = default +); +``` + +### PutAsync<TSuccess, TError, TRequest> + +```csharp +public delegate Task>> PutAsync( + HttpClient httpClient, + TRequest requestBody, + CancellationToken cancellationToken = default +); +``` + +### DeleteAsync<TSuccess, TError, TParam> + +```csharp +public delegate Task>> DeleteAsync( + HttpClient httpClient, + TParam parameters, + CancellationToken cancellationToken = default +); +``` + +### PatchAsync<TSuccess, TError, TRequest> + +```csharp +public delegate Task>> PatchAsync( + HttpClient httpClient, + TRequest requestBody, + CancellationToken cancellationToken = default +); +``` + +### HeadAsync<TSuccess, TError, TParam> + +```csharp +public delegate Task>> HeadAsync( + HttpClient httpClient, + TParam parameters, + CancellationToken cancellationToken = default +); +``` + +### OptionsAsync<TSuccess, TError, TParam> + +```csharp +public delegate Task>> OptionsAsync( + HttpClient httpClient, + TParam parameters, + CancellationToken cancellationToken = default +); +``` + +--- + +## Usage Pattern + +```csharp +// Define a typed API client using delegates +public class UserApiClient +{ + private readonly GetAsync _getUser; + private readonly PostAsync _createUser; + private readonly DeleteAsync _deleteUser; + + public UserApiClient() + { + var baseUrl = "https://api.example.com".ToAbsoluteUrl(); + + _getUser = HttpClientExtensions.CreateGet( + url: baseUrl, + buildRequest: id => new HttpRequestParts($"/users/{id}".ToRelativeUrl(), null, null), + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson + ); + + _createUser = HttpClientExtensions.CreatePost( + url: baseUrl, + buildRequest: req => new HttpRequestParts( + "/users".ToRelativeUrl(), + new StringContent(JsonSerializer.Serialize(req), Encoding.UTF8, "application/json"), + null + ), + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson + ); + } + + public Task>> GetUserAsync( + HttpClient client, int id, CancellationToken ct = default) + => _getUser(client, id, ct); + + public Task>> CreateUserAsync( + HttpClient client, CreateUserRequest request, CancellationToken ct = default) + => _createUser(client, request, ct); +} +``` diff --git a/Website/src/docs/api/httpclient-extensions.md b/Website/src/docs/api/httpclient-extensions.md new file mode 100644 index 00000000..ec1a9e82 --- /dev/null +++ b/Website/src/docs/api/httpclient-extensions.md @@ -0,0 +1,375 @@ +--- +layout: layouts/docs.njk +title: HttpClient Extensions +eleventyNavigation: + key: HttpClient Extensions + parent: API Reference + order: 1 +--- + +# HttpClient Extensions + +Extension methods for `System.Net.Http.HttpClient` that return `Result>` instead of throwing exceptions. + +**Namespace:** `RestClient.Net` + +## SendAsync + +The core method for sending HTTP requests with full control over all parameters. + +```csharp +public static async Task>> SendAsync( + this HttpClient httpClient, + AbsoluteUrl url, + HttpMethod httpMethod, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + HttpContent? requestBody = null, + HttpAction? httpOperation = null, + CancellationToken cancellationToken = default +) +``` + +### Type Parameters + +| Parameter | Description | +|-----------|-------------| +| `TSuccess` | The type to deserialize successful (2xx) responses into | +| `TError` | The type to deserialize error (non-2xx) responses into | + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `httpClient` | `HttpClient` | The HTTP client instance | +| `url` | `AbsoluteUrl` | The absolute URL for the request | +| `httpMethod` | `HttpMethod` | The HTTP method (GET, POST, PUT, etc.) | +| `deserializeSuccess` | `Deserialize` | Async function to deserialize successful responses | +| `deserializeError` | `Deserialize` | Async function to deserialize error responses | +| `headers` | `IReadOnlyDictionary?` | Optional request headers | +| `requestBody` | `HttpContent?` | Optional request body content | +| `httpOperation` | `HttpAction?` | Optional custom HTTP operation handler | +| `cancellationToken` | `CancellationToken` | Cancellation token | + +### Returns + +`Task>>` - A Result that is either: +- `Ok` containing the deserialized response for 2xx status codes +- `Error>` containing either an `ErrorResponseError` or `ExceptionError` + +--- + +## GetAsync + +Performs a GET request. + +```csharp +public static Task>> GetAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +### Example + +```csharp +var result = await httpClient.GetAsync( + url: "https://api.example.com/users/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson +); + +var output = result switch +{ + Result>.Ok>(var user) + => $"Found: {user.Name}", + Result>.Error>(var error) + => $"Error occurred", +}; +``` + +--- + +## PostAsync + +Performs a POST request with a request body. + +```csharp +public static Task>> PostAsync( + this HttpClient httpClient, + AbsoluteUrl url, + HttpContent? requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +### Example + +```csharp +var content = new StringContent( + JsonSerializer.Serialize(new { Name = "John", Email = "john@example.com" }), + Encoding.UTF8, + "application/json" +); + +var result = await httpClient.PostAsync( + url: "https://api.example.com/users".ToAbsoluteUrl(), + requestBody: content, + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson +); +``` + +--- + +## PutAsync + +Performs a PUT request with a request body. + +```csharp +public static Task>> PutAsync( + this HttpClient httpClient, + AbsoluteUrl url, + HttpContent? requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## DeleteAsync + +Performs a DELETE request. + +```csharp +public static Task>> DeleteAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## PatchAsync + +Performs a PATCH request with a request body. + +```csharp +public static Task>> PatchAsync( + this HttpClient httpClient, + AbsoluteUrl url, + HttpContent? requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## HeadAsync + +Performs a HEAD request (retrieves headers only). + +```csharp +public static Task>> HeadAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## OptionsAsync + +Performs an OPTIONS request (retrieves allowed methods). + +```csharp +public static Task>> OptionsAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## DownloadFileAsync + +Downloads a file from the specified URL to a stream. + +```csharp +public static Task>> DownloadFileAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Stream destinationStream, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +### Example + +```csharp +using var fileStream = File.Create("downloaded-file.pdf"); +var result = await httpClient.DownloadFileAsync( + url: "https://api.example.com/files/report.pdf".ToAbsoluteUrl(), + destinationStream: fileStream, + deserializeError: DeserializeJson +); + +if (result.IsOk) +{ + Console.WriteLine("File downloaded successfully"); +} +``` + +--- + +## UploadFileAsync + +Uploads a file to the specified URL. + +```csharp +public static Task>> UploadFileAsync( + this HttpClient httpClient, + AbsoluteUrl url, + HttpContent requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +### Example + +```csharp +using var fileStream = File.OpenRead("document.pdf"); +var content = new ProgressReportingHttpContent( + content: fileStream, + progress: (current, total) => Console.WriteLine($"Progress: {current}/{total}") +); + +var result = await httpClient.UploadFileAsync( + url: "https://api.example.com/upload".ToAbsoluteUrl(), + requestBody: content, + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson +); +``` + +--- + +## Request Factory Methods + +These methods create reusable request delegates that can be invoked multiple times. + +### CreateGet + +Creates a GET request delegate. + +```csharp +public static GetAsync CreateGet( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreatePost + +Creates a POST request delegate. + +```csharp +public static PostAsync CreatePost( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreatePut + +Creates a PUT request delegate. + +```csharp +public static PutAsync CreatePut( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreateDelete + +Creates a DELETE request delegate. + +```csharp +public static DeleteAsync CreateDelete( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreatePatch + +Creates a PATCH request delegate. + +```csharp +public static PatchAsync CreatePatch( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### Factory Example + +```csharp +// Define the request factory once +var getUser = HttpClientExtensions.CreateGet( + url: "https://api.example.com".ToAbsoluteUrl(), + buildRequest: userId => new HttpRequestParts( + RelativeUrl: $"/users/{userId}".ToRelativeUrl(), + Body: null, + Headers: null + ), + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson +); + +// Use it multiple times +var user1 = await getUser(httpClient, 1, CancellationToken.None); +var user2 = await getUser(httpClient, 2, CancellationToken.None); +``` diff --git a/Website/src/docs/api/httpclientfactory-extensions.md b/Website/src/docs/api/httpclientfactory-extensions.md new file mode 100644 index 00000000..ae126162 --- /dev/null +++ b/Website/src/docs/api/httpclientfactory-extensions.md @@ -0,0 +1,324 @@ +--- +layout: layouts/docs.njk +title: IHttpClientFactory Extensions +eleventyNavigation: + key: IHttpClientFactory Extensions + parent: API Reference + order: 2 +--- + +# IHttpClientFactory Extensions + +Extension methods for `System.Net.Http.IHttpClientFactory` that provide the same type-safe HTTP operations as the HttpClient extensions, with built-in client creation and connection pooling. + +**Namespace:** `RestClient.Net` + +## Overview + +These extension methods work with `IHttpClientFactory` from Microsoft.Extensions.Http, enabling proper HTTP connection management in ASP.NET Core applications. + +## SendAsync + +The core method for sending HTTP requests with full control over all parameters. + +```csharp +public static async Task>> SendAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + HttpMethod httpMethod, + Deserialize deserializeSuccess, + Deserialize deserializeError, + HttpContent? requestBody = null, + IReadOnlyDictionary? headers = null, + HttpAction? httpOperation = null, + CancellationToken cancellationToken = default +) +``` + +### Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `httpClientFactory` | `IHttpClientFactory` | The HTTP client factory instance | +| `clientName` | `string` | The name of the configured HTTP client | +| `url` | `AbsoluteUrl` | The absolute URL for the request | +| `httpMethod` | `HttpMethod` | The HTTP method (GET, POST, PUT, etc.) | +| `deserializeSuccess` | `Deserialize` | Async function to deserialize successful responses | +| `deserializeError` | `Deserialize` | Async function to deserialize error responses | +| `requestBody` | `HttpContent?` | Optional request body content | +| `headers` | `IReadOnlyDictionary?` | Optional request headers | +| `httpOperation` | `HttpAction?` | Optional custom HTTP operation handler | +| `cancellationToken` | `CancellationToken` | Cancellation token | + +--- + +## GetAsync + +Performs a GET request using a named HTTP client. + +```csharp +public static Task>> GetAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +### Example + +```csharp +// In Program.cs - configure the named client +builder.Services.AddHttpClient("api", client => +{ + client.BaseAddress = new Uri("https://api.example.com"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); +}); + +// In your service - use the factory +public class UserService(IHttpClientFactory factory) +{ + public async Task>> GetUserAsync(int id) + { + return await factory.GetAsync( + clientName: "api", + url: $"https://api.example.com/users/{id}".ToAbsoluteUrl(), + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson + ); + } +} +``` + +--- + +## PostAsync + +Performs a POST request with a request body. + +```csharp +public static Task>> PostAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + HttpContent? requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## PutAsync + +Performs a PUT request with a request body. + +```csharp +public static Task>> PutAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + HttpContent? requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## DeleteAsync + +Performs a DELETE request. + +```csharp +public static Task>> DeleteAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## PatchAsync + +Performs a PATCH request with a request body. + +```csharp +public static Task>> PatchAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + HttpContent requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## DownloadFileAsync + +Downloads a file from the specified URL to a stream. + +```csharp +public static Task>> DownloadFileAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + Stream destinationStream, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## UploadFileAsync + +Uploads a file with progress reporting. + +```csharp +public static Task>> UploadFileAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + ProgressReportingHttpContent fileStream, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## Request Factory Methods + +These static methods create reusable request delegates. + +### CreateGet + +```csharp +public static GetAsync CreateGet( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreatePost + +```csharp +public static PostAsync CreatePost( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreatePut + +```csharp +public static PutAsync CreatePut( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreateDelete + +```csharp +public static DeleteAsync CreateDelete( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreatePatch + +```csharp +public static PatchAsync CreatePatch( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreateHead + +```csharp +public static HeadAsync CreateHead( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreateOptions + +```csharp +public static OptionsAsync CreateOptions( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +--- + +## Configuration Example + +```csharp +// Program.cs +var builder = WebApplication.CreateBuilder(args); + +// Configure named HTTP clients +builder.Services.AddHttpClient("github", client => +{ + client.BaseAddress = new Uri("https://api.github.com"); + client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json"); + client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0"); +}); + +builder.Services.AddHttpClient("internal-api", client => +{ + client.BaseAddress = new Uri("https://internal.example.com"); +}) +.AddPolicyHandler(GetRetryPolicy()); // Add Polly policies + +// Register services that use the factory +builder.Services.AddScoped(); +``` + +## Best Practices + +1. **Use named clients** - Configure different clients for different APIs +2. **Configure base addresses** - Set `BaseAddress` in the client configuration +3. **Add default headers** - Configure common headers like Accept, Authorization +4. **Use Polly** - Add retry and circuit breaker policies for resilience +5. **Avoid capturing HttpClient** - Let the factory create new clients as needed diff --git a/Website/src/docs/api/httperror.md b/Website/src/docs/api/httperror.md new file mode 100644 index 00000000..b77169ed --- /dev/null +++ b/Website/src/docs/api/httperror.md @@ -0,0 +1,302 @@ +--- +layout: layouts/docs.njk +title: HttpError Type +eleventyNavigation: + key: HttpError Type + parent: API Reference + order: 6 +--- + +# HttpError<TError> + +Represents HTTP-specific errors that can occur during web requests. This sealed hierarchy ensures complete error handling coverage for HTTP operations. + +**Namespace:** `Outcome` + +## Overview + +`HttpError` is a discriminated union with exactly two cases: +- `ErrorResponseError` - The server returned an error response (4xx, 5xx) +- `ExceptionError` - An exception occurred (network, timeout, etc.) + +```csharp +public abstract partial record HttpError +``` + +## Type Parameter + +| Parameter | Description | +|-----------|-------------| +| `TError` | The type used to deserialize error response bodies | + +--- + +## Nested Types + +### ErrorResponseError + +Represents an HTTP error response from the server. + +```csharp +public sealed record ErrorResponseError( + TError Body, + HttpStatusCode StatusCode, + HttpResponseHeaders Headers +) : HttpError +``` + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `Body` | `TError` | The deserialized error response body | +| `StatusCode` | `HttpStatusCode` | The HTTP status code (400, 404, 500, etc.) | +| `Headers` | `HttpResponseHeaders` | The response headers | + +### ExceptionError + +Represents an error caused by an exception during the HTTP operation. + +```csharp +public sealed record ExceptionError(Exception Exception) : HttpError +``` + +#### Properties + +| Property | Type | Description | +|----------|------|-------------| +| `Exception` | `Exception` | The exception that was caught | + +--- + +## Properties + +### IsExceptionError + +Returns `true` if this error was caused by an exception. + +```csharp +public bool IsExceptionError { get; } +``` + +### IsErrorResponse + +Returns `true` if this error was caused by an HTTP error response. + +```csharp +public bool IsErrorResponse { get; } +``` + +--- + +## Static Factory Methods + +### FromException + +Creates an HttpError from an exception. + +```csharp +public static HttpError FromException(Exception exception) +``` + +### Example + +```csharp +try +{ + // HTTP operation +} +catch (HttpRequestException ex) +{ + return Result>.Failure( + HttpError.FromException(ex) + ); +} +``` + +### FromErrorResponse + +Creates an HttpError from a server error response. + +```csharp +public static HttpError FromErrorResponse( + TError body, + HttpStatusCode statusCode, + HttpResponseHeaders headers +) +``` + +### Example + +```csharp +if (!response.IsSuccessStatusCode) +{ + var errorBody = await DeserializeError(response); + return Result>.Failure( + HttpError.FromErrorResponse( + errorBody, + response.StatusCode, + response.Headers + ) + ); +} +``` + +--- + +## Instance Methods + +### Match + +Transforms this HttpError using the provided functions. + +```csharp +public TError Match( + Func onException, + Func onErrorResponse +) +``` + +### Example + +```csharp +string errorMessage = httpError.Match( + onException: ex => $"Network error: {ex.Message}", + onErrorResponse: (body, status, headers) => + $"API error {(int)status}: {body.Message}" +); +``` + +### IsErrorResponseError + +Attempts to extract ErrorResponseError details using out parameters. + +```csharp +public bool IsErrorResponseError( + out TError body, + out HttpStatusCode statusCode, + out HttpResponseHeaders? headers +) +``` + +### Example + +```csharp +if (httpError.IsErrorResponseError(out var body, out var status, out var headers)) +{ + Console.WriteLine($"Status: {status}, Message: {body.Message}"); +} +``` + +--- + +## Pattern Matching + +The recommended way to handle HttpError: + +```csharp +var message = httpError switch +{ + HttpError.ErrorResponseError(var body, var status, var headers) + => status switch + { + HttpStatusCode.NotFound => "Resource not found", + HttpStatusCode.Unauthorized => "Please log in", + HttpStatusCode.Forbidden => "Access denied", + HttpStatusCode.BadRequest => $"Invalid request: {body.Message}", + _ => $"Server error ({(int)status}): {body.Message}" + }, + + HttpError.ExceptionError(var ex) + => ex switch + { + HttpRequestException => "Network error - check your connection", + TaskCanceledException => "Request timed out", + _ => $"Unexpected error: {ex.Message}" + } +}; +``` + +### With Type Aliases + +```csharp +global using ResponseError = Outcome.HttpError.ErrorResponseError; +global using ExceptionError = Outcome.HttpError.ExceptionError; + +var message = httpError switch +{ + ResponseError(var body, var status, _) => $"Error {status}: {body.Message}", + ExceptionError(var ex) => $"Exception: {ex.Message}", +}; +``` + +--- + +## Common Error Types + +Define a strongly-typed error model: + +```csharp +public record ApiError( + string Message, + string? Code, + IDictionary? ValidationErrors +); +``` + +### Handling Validation Errors + +```csharp +var result = await httpClient.PostAsync(...); + +if (result is Result>.Error>( + HttpError.ErrorResponseError(var error, HttpStatusCode.BadRequest, _))) +{ + if (error.ValidationErrors is not null) + { + foreach (var (field, messages) in error.ValidationErrors) + { + Console.WriteLine($"{field}: {string.Join(", ", messages)}"); + } + } +} +``` + +--- + +## Integration with Result + +HttpError is typically used as the failure type in Result: + +```csharp +Result> +``` + +### Complete Pattern Matching + +```csharp +var output = result switch +{ + Result>.Ok>(var user) + => $"Success: {user.Name}", + + Result>.Error>( + HttpError.ErrorResponseError(var body, var status, _)) + => $"API Error {status}: {body.Message}", + + Result>.Error>( + HttpError.ExceptionError(var ex)) + => $"Exception: {ex.Message}", +}; +``` + +--- + +## Best Practices + +1. **Define error models** - Create typed classes for your API's error responses +2. **Handle both cases** - Always handle both `ErrorResponseError` and `ExceptionError` +3. **Check status codes** - Different status codes often require different handling +4. **Log exceptions** - `ExceptionError` contains the full exception for debugging +5. **Use type aliases** - Simplify pattern matching with global using statements +6. **Inspect headers** - Rate limit info and other metadata are in response headers diff --git a/Website/src/docs/api/index.md b/Website/src/docs/api/index.md new file mode 100644 index 00000000..3fc929a2 --- /dev/null +++ b/Website/src/docs/api/index.md @@ -0,0 +1,79 @@ +--- +layout: layouts/docs.njk +title: API Reference +eleventyNavigation: + key: API Reference + parent: Documentation + order: 1 +--- + +# API Reference + +Complete reference documentation for RestClient.Net and the Outcome library. + +## Core Packages + +### RestClient.Net + +The main library providing type-safe HTTP operations with discriminated union return types. + +- [HttpClient Extensions](/docs/api/httpclient-extensions/) - Extension methods for `HttpClient` +- [IHttpClientFactory Extensions](/docs/api/httpclientfactory-extensions/) - Extension methods for `IHttpClientFactory` +- [Delegates](/docs/api/delegates/) - Function delegates for serialization/deserialization +- [Utilities](/docs/api/utilities/) - Helper classes like `ProgressReportingHttpContent` + +### Outcome + +The functional programming library providing discriminated unions for error handling. + +- [Result Type](/docs/api/result/) - The core `Result` type +- [HttpError Type](/docs/api/httperror/) - HTTP-specific error representation +- [Result Extensions](/docs/api/result-extensions/) - Extension methods for `Result` +- [Unit Type](/docs/api/unit/) - The void equivalent for functional programming + +### Code Generators + +Tools for generating type-safe clients from API specifications. + +- [OpenAPI Generator](/docs/api/openapi-generator/) - Generate C# clients from OpenAPI/Swagger specs +- [MCP Generator](/docs/api/mcp-generator/) - Generate Model Context Protocol servers from OpenAPI specs + +## Quick Start + +```csharp +using RestClient.Net; +using Outcome; + +// Make a type-safe GET request +var result = await httpClient.GetAsync( + url: "https://api.example.com/users/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson +); + +// Handle the result with pattern matching +var message = result switch +{ + Result>.Ok>(var user) + => $"Found user: {user.Name}", + Result>.Error>(var error) + => error switch + { + HttpError.ErrorResponseError(var body, var status, _) + => $"API Error {status}: {body.Message}", + HttpError.ExceptionError(var ex) + => $"Exception: {ex.Message}", + _ => "Unknown error" + }, + _ => "Unexpected" +}; +``` + +## Design Philosophy + +RestClient.Net follows Railway Oriented Programming principles: + +1. **No exceptions for expected errors** - HTTP errors are returned as values, not thrown +2. **Complete error handling** - Pattern matching ensures all cases are handled +3. **Type safety** - Generic parameters provide compile-time type checking +4. **Composability** - Results can be mapped, bound, and combined functionally diff --git a/Website/src/docs/api/mcp-generator.md b/Website/src/docs/api/mcp-generator.md new file mode 100644 index 00000000..62d90448 --- /dev/null +++ b/Website/src/docs/api/mcp-generator.md @@ -0,0 +1,285 @@ +--- +layout: layouts/docs.njk +title: MCP Generator API Reference +description: Generate Model Context Protocol (MCP) servers from OpenAPI specifications +eleventyNavigation: + key: MCP Generator + parent: API Reference + order: 31 +--- + +# MCP Generator + +Generates Model Context Protocol (MCP) server code from OpenAPI specifications. + +**Namespace:** `RestClient.Net.McpGenerator` + +## Overview + +The MCP Generator creates MCP server tools that wrap RestClient.Net extension methods generated from OpenAPI specifications. This enables AI assistants like Claude to interact with your APIs using type-safe, auto-generated tools. + +## McpServerGenerator + +The main entry point for generating MCP server code. + +### Generate + +Generates MCP server tools code from an OpenAPI document. + +```csharp +public static Result Generate( + string openApiContent, + string @namespace, + string serverName, + string extensionsNamespace, + ISet? includeTags = null +) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `openApiContent` | `string` | The OpenAPI document content (JSON or YAML) | +| `namespace` | `string` | The namespace for generated MCP tools | +| `serverName` | `string` | The MCP server name | +| `extensionsNamespace` | `string` | The namespace of the pre-generated RestClient.Net extensions | +| `includeTags` | `ISet?` | Optional set of tags to include. If specified, only operations with these tags are generated | + +**Returns:** `Result` - A Result containing the generated C# code or an error message. + +**Example:** +```csharp +var openApiSpec = await File.ReadAllTextAsync("petstore.yaml"); + +var result = McpServerGenerator.Generate( + openApiContent: openApiSpec, + @namespace: "PetStore.McpServer", + serverName: "PetStoreServer", + extensionsNamespace: "PetStore.Client", + includeTags: new HashSet { "pets", "store" } +); + +result.Match( + onSuccess: code => + { + Console.WriteLine("MCP server generated!"); + await File.WriteAllTextAsync("McpServer.cs", code); + }, + onError: error => Console.WriteLine($"Generation failed: {error}") +); +``` + +--- + +## McpToolGenerator + +Generates MCP tool classes that use RestClient.Net extensions. + +### GenerateTools + +Generates MCP tools that wrap generated extension methods. + +```csharp +public static string GenerateTools( + OpenApiDocument document, + string @namespace, + string serverName, + string extensionsNamespace, + ISet? includeTags = null +) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `document` | `OpenApiDocument` | The OpenAPI document | +| `namespace` | `string` | The namespace for the MCP server | +| `serverName` | `string` | The MCP server name | +| `extensionsNamespace` | `string` | The namespace of the extensions | +| `includeTags` | `ISet?` | Optional set of tags to filter operations. If specified, only operations with these tags are generated | + +**Returns:** `string` - The generated MCP tools code. + +**Example:** +```csharp +using Microsoft.OpenApi.Reader; + +var document = OpenApiDocument.Load("petstore.yaml").OpenApiDocument; + +var toolsCode = McpToolGenerator.GenerateTools( + document: document, + @namespace: "PetStore.McpServer", + serverName: "PetStoreServer", + extensionsNamespace: "PetStore.Client", + includeTags: null // Generate all tools +); + +Console.WriteLine(toolsCode); +``` + +--- + +## Generated Code Structure + +The MCP Generator produces code with the following structure: + +### Tool Classes + +For each OpenAPI operation, a tool class is generated: + +```csharp +[McpServerToolType] +public static class GetPetByIdTool +{ + [McpServerTool(Name = "getPetById")] + [Description("Returns a single pet")] + public static async Task Execute( + HttpClient httpClient, + [Description("ID of pet to return")] long petId, + CancellationToken cancellationToken = default) + { + var result = await httpClient.GetPetByIdAsync(petId, cancellationToken); + return result.Match( + onSuccess: pet => JsonSerializer.Serialize(pet), + onError: error => $"Error: {error}" + ); + } +} +``` + +### Server Registration + +The generated code includes server registration: + +```csharp +public static class McpServerRegistration +{ + public static void RegisterTools(IMcpServer server) + { + server.AddTool(); + server.AddTool(); + server.AddTool(); + // ... more tools + } +} +``` + +--- + +## Tag Filtering + +Use the `includeTags` parameter to generate tools for specific API sections: + +```csharp +// Only generate tools for "pets" and "store" tagged operations +var result = McpServerGenerator.Generate( + openApiContent: openApiSpec, + @namespace: "PetStore.McpServer", + serverName: "PetStoreServer", + extensionsNamespace: "PetStore.Client", + includeTags: new HashSet { "pets", "store" } +); +``` + +This is useful for: +- Creating focused MCP servers for specific API domains +- Reducing the number of tools exposed to AI assistants +- Generating separate MCP servers for different user roles + +--- + +## Prerequisites + +Before using the MCP Generator, you must first generate the RestClient.Net extensions: + +```bash +# Step 1: Generate RestClient.Net extensions +restclient-openapi generate \ + --input petstore.yaml \ + --output ./Generated \ + --namespace PetStore.Client + +# Step 2: Generate MCP server +restclient-mcp generate \ + --input petstore.yaml \ + --output ./McpServer \ + --namespace PetStore.McpServer \ + --server-name PetStoreServer \ + --extensions-namespace PetStore.Client +``` + +--- + +## CLI Usage + +The MCP Generator is available as a command-line tool: + +```bash +restclient-mcp generate \ + --input petstore.yaml \ + --output ./McpServer \ + --namespace PetStore.McpServer \ + --server-name PetStoreServer \ + --extensions-namespace PetStore.Client \ + --tags pets,store +``` + +### CLI Options + +| Option | Description | +|--------|-------------| +| `--input` | Path to the OpenAPI specification file | +| `--output` | Output directory for generated code | +| `--namespace` | Namespace for the generated MCP server | +| `--server-name` | Name of the MCP server | +| `--extensions-namespace` | Namespace of the pre-generated extensions | +| `--tags` | Comma-separated list of tags to include (optional) | + +--- + +## Integration with Claude + +The generated MCP server can be used with Claude Desktop or other MCP-compatible clients: + +### claude_desktop_config.json + +```json +{ + "mcpServers": { + "petstore": { + "command": "dotnet", + "args": ["run", "--project", "/path/to/PetStore.McpServer"], + "env": { + "PETSTORE_API_KEY": "your-api-key" + } + } + } +} +``` + +### Using with Claude + +Once configured, Claude can use the generated tools: + +``` +Claude: I'll look up the pet with ID 123 for you. + +[Using tool: getPetById with petId=123] + +The pet with ID 123 is a golden retriever named "Buddy" who is available for adoption. +``` + +--- + +## Best Practices + +1. **Generate extensions first** - Always generate RestClient.Net extensions before MCP tools +2. **Use tag filtering** - Generate focused servers for specific API domains +3. **Document your API** - Good OpenAPI descriptions become good tool descriptions +4. **Handle authentication** - Configure HttpClient with appropriate auth headers +5. **Error handling** - The generated tools return error messages as strings for AI consumption + +## See Also + +- [OpenAPI Generator](./openapi-generator) - Generate RestClient.Net extensions +- [Getting Started with MCP](/docs/mcp) - Tutorial and examples diff --git a/Website/src/docs/api/openapi-generator.md b/Website/src/docs/api/openapi-generator.md new file mode 100644 index 00000000..8094c857 --- /dev/null +++ b/Website/src/docs/api/openapi-generator.md @@ -0,0 +1,397 @@ +--- +layout: layouts/docs.njk +title: OpenAPI Generator API Reference +description: Code generation tools for creating type-safe C# clients from OpenAPI specifications +eleventyNavigation: + key: OpenAPI Generator + parent: API Reference + order: 30 +--- + +# OpenAPI Generator + +Generates C# extension methods and model classes from OpenAPI specifications. + +**Namespace:** `RestClient.Net.OpenApiGenerator` + +## Overview + +The OpenAPI Generator parses OpenAPI (Swagger) documents and generates strongly-typed C# code that integrates with RestClient.Net. It uses the Microsoft.OpenApi library for parsing. + +## OpenApiCodeGenerator + +The main entry point for code generation from OpenAPI specifications. + +### Generate + +Generates code from an OpenAPI document. + +```csharp +public static Result Generate( + string openApiContent, + string @namespace, + string className, + string outputPath, + string? baseUrlOverride = null, + string jsonNamingPolicy = "camelCase", + bool caseInsensitive = false +) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `openApiContent` | `string` | The OpenAPI document content (JSON or YAML) | +| `namespace` | `string` | The namespace for generated code | +| `className` | `string` | The class name for extension methods | +| `outputPath` | `string` | The directory path where generated files will be saved | +| `baseUrlOverride` | `string?` | Optional base URL override. Use when the OpenAPI spec has a relative server URL | +| `jsonNamingPolicy` | `string` | JSON naming policy: "camelCase", "PascalCase", or "snake_case" | +| `caseInsensitive` | `bool` | Enable case-insensitive JSON deserialization | + +**Returns:** `Result` - A Result containing either the generated code or an error message. + +**Example:** +```csharp +var openApiSpec = await File.ReadAllTextAsync("petstore.yaml"); + +var result = OpenApiCodeGenerator.Generate( + openApiContent: openApiSpec, + @namespace: "PetStore.Client", + className: "PetStoreExtensions", + outputPath: "./Generated", + baseUrlOverride: "https://api.petstore.com", + jsonNamingPolicy: "camelCase", + caseInsensitive: true +); + +result.Match( + onSuccess: generated => + { + Console.WriteLine("Extension methods generated!"); + Console.WriteLine(generated.ExtensionMethodsCode); + }, + onError: error => Console.WriteLine($"Generation failed: {error}") +); +``` + +--- + +## GeneratorResult + +Represents the result of code generation. + +```csharp +public record GeneratorResult( + string ExtensionMethodsCode, + string ModelsCode +) +``` + +**Properties:** +| Property | Type | Description | +|----------|------|-------------| +| `ExtensionMethodsCode` | `string` | The generated extension methods code | +| `ModelsCode` | `string` | The generated models code | + +--- + +## ExtensionMethodGenerator + +Generates C# extension methods from OpenAPI operations. + +### GenerateExtensionMethods + +Generates extension methods from an OpenAPI document. + +```csharp +public static (string ExtensionMethods, string TypeAliases) GenerateExtensionMethods( + OpenApiDocument document, + string @namespace, + string className, + string baseUrl, + string basePath, + string jsonNamingPolicy, + bool caseInsensitive +) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `document` | `OpenApiDocument` | The OpenAPI document | +| `namespace` | `string` | The namespace for generated code | +| `className` | `string` | The class name for extension methods | +| `baseUrl` | `string` | The base URL for API requests (not used, kept for API compatibility) | +| `basePath` | `string` | The base path for API requests | +| `jsonNamingPolicy` | `string` | JSON naming policy: "camelCase", "PascalCase", or "snake_case" | +| `caseInsensitive` | `bool` | Enable case-insensitive JSON deserialization | + +**Returns:** A tuple containing the extension methods code and type aliases code. + +--- + +## ModelGenerator + +Generates C# model classes from OpenAPI schemas. + +### GenerateModels + +Generates C# models from an OpenAPI document. + +```csharp +public static string GenerateModels(OpenApiDocument document, string @namespace) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `document` | `OpenApiDocument` | The OpenAPI document | +| `namespace` | `string` | The namespace for the generated models | + +**Returns:** `string` - The generated models code. + +--- + +### GenerateModel + +Generates a single C# model record from an OpenAPI schema. + +```csharp +public static string GenerateModel( + string name, + OpenApiSchema schema, + IDictionary? schemas = null +) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `name` | `string` | The name of the model | +| `schema` | `OpenApiSchema` | The OpenAPI schema | +| `schemas` | `IDictionary?` | Optional schemas dictionary to check for string enums | + +**Returns:** `string` - The generated model code. + +--- + +### MapOpenApiType + +Maps an OpenAPI schema to a C# type. + +```csharp +public static string MapOpenApiType( + IOpenApiSchema schema, + IDictionary? schemas = null +) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `schema` | `IOpenApiSchema` | The OpenAPI schema | +| `schemas` | `IDictionary?` | Optional schemas dictionary to check for string enums | + +**Returns:** `string` - The C# type name. + +--- + +### IsStringEnum + +Checks if a schema is a string enum. + +```csharp +public static bool IsStringEnum(OpenApiSchema schema) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `schema` | `OpenApiSchema` | The schema to check | + +**Returns:** `bool` - True if the schema is a string enum. + +--- + +### SanitizeDescription + +Sanitizes a description for use in XML comments. + +```csharp +public static string SanitizeDescription(string? description) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `description` | `string?` | The description to sanitize | + +**Returns:** `string` - A single-line description safe for XML comments. + +--- + +## ParameterInfo + +Parameter information for OpenAPI operations. + +```csharp +public record ParameterInfo( + string Name, + string Type, + bool IsPath, + bool IsHeader, + string? OriginalName, + bool Required, + string? DefaultValue +) +``` + +**Properties:** +| Property | Type | Description | +|----------|------|-------------| +| `Name` | `string` | The parameter name (sanitized for C#) | +| `Type` | `string` | The parameter type | +| `IsPath` | `bool` | Whether the parameter is a path parameter | +| `IsHeader` | `bool` | Whether the parameter is a header parameter | +| `OriginalName` | `string?` | The original parameter name from the OpenAPI spec | +| `Required` | `bool` | Whether the parameter is required | +| `DefaultValue` | `string?` | The default value for the parameter | + +--- + +## CodeGenerationHelpers + +Helper methods for code generation. + +### ToPascalCase + +Converts a string to PascalCase. + +```csharp +public static string ToPascalCase(string text) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `text` | `string` | The text to convert | + +**Returns:** `string` - The PascalCase version of the text. + +--- + +### ToCamelCase + +Converts a string to camelCase. + +```csharp +public static string ToCamelCase(string text) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `text` | `string` | The text to convert | + +**Returns:** `string` - The camelCase version of the text. + +--- + +### Indent + +Indents text by the specified number of levels. + +```csharp +public static string Indent(string text, int level) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `text` | `string` | The text to indent | +| `level` | `int` | The indentation level (1 level = 4 spaces) | + +**Returns:** `string` - The indented text. + +--- + +### BuildPathExpression + +Builds a path expression from a path template. + +```csharp +public static string BuildPathExpression(string path) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `path` | `string` | The path template | + +**Returns:** `string` - The path expression. + +--- + +### SanitizePathParameters + +Replaces path parameter names with their sanitized C# equivalents. + +```csharp +public static string SanitizePathParameters(string path, List parameters) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `path` | `string` | The path template with original parameter names | +| `parameters` | `List` | List of parameters with original and sanitized names | + +**Returns:** `string` - The path with sanitized parameter names. + +--- + +## UrlParser + +Parses base URLs and paths from OpenAPI documents. + +### GetBaseUrlAndPath + +Gets the base URL and path from an OpenAPI document. + +```csharp +public static Result<(string BaseUrl, string BasePath), string> GetBaseUrlAndPath( + OpenApiDocument document, + string? baseUrlOverride = null +) +``` + +**Parameters:** +| Parameter | Type | Description | +|-----------|------|-------------| +| `document` | `OpenApiDocument` | The OpenAPI document | +| `baseUrlOverride` | `string?` | Optional base URL override | + +**Returns:** `Result<(string BaseUrl, string BasePath), string>` - A result containing the base URL and path, or an error message. + +--- + +## CLI Usage + +The OpenAPI Generator is also available as a command-line tool: + +```bash +restclient-openapi generate \ + --input petstore.yaml \ + --output ./Generated \ + --namespace PetStore.Client \ + --class PetStoreExtensions \ + --base-url https://api.petstore.com \ + --naming camelCase \ + --case-insensitive +``` + +## See Also + +- [MCP Generator](./mcp-generator) - Generate MCP servers from OpenAPI specs +- [Getting Started with OpenAPI](/docs/openapi) - Tutorial and examples diff --git a/Website/src/docs/api/result-extensions.md b/Website/src/docs/api/result-extensions.md new file mode 100644 index 00000000..14c8c2ae --- /dev/null +++ b/Website/src/docs/api/result-extensions.md @@ -0,0 +1,311 @@ +--- +layout: layouts/docs.njk +title: Result Extensions +eleventyNavigation: + key: Result Extensions + parent: API Reference + order: 7 +--- + +# ResultExtensions + +Extension methods that provide additional functional programming utilities for the Result type. + +**Namespace:** `Outcome` + +## Sequence + +Transforms a sequence of Results into a Result of a sequence. If all Results are Ok, returns Ok with all values. If any Result is Error, returns the first Error encountered. + +```csharp +public static Result, TFailure> Sequence( + this IEnumerable> results +) +``` + +This is the sequence/traverse operation for Result, enabling all-or-nothing semantics for collections. + +### Example + +```csharp +var results = new[] +{ + new Result.Ok(1), + new Result.Ok(2), + new Result.Ok(3), +}; + +var combined = results.Sequence(); +// combined is Ok([1, 2, 3]) + +var withError = new[] +{ + new Result.Ok(1), + Result.Failure("Error on item 2"), + new Result.Ok(3), +}; + +var combinedWithError = withError.Sequence(); +// combinedWithError is Error("Error on item 2") +``` + +### Use Case: Batch Operations + +```csharp +var userIds = new[] { 1, 2, 3, 4, 5 }; + +var results = await Task.WhenAll( + userIds.Select(id => httpClient.GetAsync(...)) +); + +var allUsers = results.Sequence(); +// Either Ok with all users, or Error with first failure +``` + +--- + +## Flatten + +Flattens a nested Result structure. + +```csharp +public static Result Flatten( + this Result, TFailure> result +) +``` + +### Example + +```csharp +Result, string> nested = + new Result, string>.Ok, string>( + new Result.Ok(42) + ); + +Result flattened = nested.Flatten(); +// flattened is Ok(42) +``` + +--- + +## Combine + +Combines two Results using a combining function. Both Results must be Ok for the combination to succeed. + +```csharp +public static Result Combine( + this Result result1, + Result result2, + Func combiner +) +``` + +### Example + +```csharp +var nameResult = new Result.Ok("John"); +var ageResult = new Result.Ok(30); + +var combined = nameResult.Combine( + ageResult, + (name, age) => new Person(name, age) +); +// combined is Ok(Person("John", 30)) + +var errorName = Result.Failure("Name required"); +var combinedWithError = errorName.Combine( + ageResult, + (name, age) => new Person(name, age) +); +// combinedWithError is Error("Name required") +``` + +### Use Case: Validation + +```csharp +var validatedName = ValidateName(input.Name); +var validatedEmail = ValidateEmail(input.Email); +var validatedAge = ValidateAge(input.Age); + +// Combine all validations +var userResult = validatedName + .Combine(validatedEmail, (name, email) => (name, email)) + .Combine(validatedAge, (pair, age) => new User(pair.name, pair.email, age)); +``` + +--- + +## Filter + +Filters a Result based on a predicate. + +```csharp +public static Result Filter( + this Result result, + Func predicate, + TFailure errorOnFalse +) +``` + +- If Ok and predicate returns true: returns the original Result +- If Ok and predicate returns false: returns Error with the provided error +- If already Error: returns unchanged + +### Example + +```csharp +var result = new Result.Ok(5); + +var filtered = result.Filter( + n => n > 0, + "Number must be positive" +); +// filtered is Ok(5) + +var filteredNegative = result.Filter( + n => n > 10, + "Number must be greater than 10" +); +// filteredNegative is Error("Number must be greater than 10") +``` + +### Use Case: Post-Validation + +```csharp +var user = await GetUserAsync(id); + +var validUser = user.Filter( + u => u.IsActive, + HttpError.FromErrorResponse( + new ApiError("User account is deactivated"), + HttpStatusCode.Forbidden, + null! + ) +); +``` + +--- + +## GetValueOrThrow + +**Warning: Dangerous method - use with caution!** + +Extracts the success value or throws an exception. Only use in tests. + +```csharp +public static TSuccess GetValueOrThrow( + this Result result, + string errorMessage = "Expected success result" +) +``` + +### Example + +```csharp +// In tests only! +[Test] +public async Task GetUser_ReturnsUser() +{ + var result = await _userService.GetUserAsync(1); + + var user = result.GetValueOrThrow("Expected user to be found"); + + Assert.AreEqual("John", user.Name); +} +``` + +--- + +## GetErrorOrThrow + +**Warning: Dangerous method - use with caution!** + +Extracts the error value or throws an exception. Only use in tests. + +```csharp +public static TFailure GetErrorOrThrow( + this Result result, + string errorMessage = "Expected error result" +) +``` + +### Example + +```csharp +// In tests only! +[Test] +public async Task GetUser_InvalidId_ReturnsNotFound() +{ + var result = await _userService.GetUserAsync(-1); + + var error = result.GetErrorOrThrow("Expected error for invalid ID"); + + Assert.IsTrue(error.IsErrorResponse); +} +``` + +--- + +## Chaining Extensions + +Extensions can be chained for expressive code: + +```csharp +var result = await httpClient.GetAsync(...) + .Map(user => user with { LastAccessed = DateTime.UtcNow }) + .Filter(user => user.IsActive, CreateDeactivatedError()) + .Tap(user => logger.LogInformation("Fetched user {Id}", user.Id)); +``` + +--- + +## Async Extensions (Custom) + +You can create async versions for your project: + +```csharp +public static class AsyncResultExtensions +{ + public static async Task> MapAsync( + this Task> resultTask, + Func> mapper + ) + { + var result = await resultTask; + return result switch + { + Result.Ok(var value) + => new Result.Ok(await mapper(value)), + Result.Error(var error) + => Result.Failure(error), + _ => throw new InvalidOperationException() + }; + } + + public static async Task> BindAsync( + this Task> resultTask, + Func>> binder + ) + { + var result = await resultTask; + return result switch + { + Result.Ok(var value) + => await binder(value), + Result.Error(var error) + => Result.Failure(error), + _ => throw new InvalidOperationException() + }; + } +} +``` + +--- + +## Best Practices + +1. **Use `Sequence` for batch operations** - Fail fast on first error +2. **Use `Combine` for validation** - Collect multiple validations +3. **Use `Filter` for conditional checks** - Add post-operation validation +4. **Never use `GetValueOrThrow` in production** - Only for tests +5. **Create async extensions** - Add project-specific async helpers diff --git a/Website/src/docs/api/result.md b/Website/src/docs/api/result.md new file mode 100644 index 00000000..bc6de34d --- /dev/null +++ b/Website/src/docs/api/result.md @@ -0,0 +1,347 @@ +--- +layout: layouts/docs.njk +title: Result Type +eleventyNavigation: + key: Result Type + parent: API Reference + order: 5 +--- + +# Result<TSuccess, TFailure> + +A discriminated union representing either a successful result or a failure. This type enforces explicit error handling and eliminates the need for exceptions in business logic. + +**Namespace:** `Outcome` + +## Overview + +The `Result` type follows the Railway Oriented Programming pattern. Every operation returns either: +- `Ok` - The "happy path" containing the success value +- `Error` - The "sad path" containing the error value + +```csharp +public abstract partial record Result +``` + +## Nested Types + +### Ok<TSuccess, TFailure> + +Represents a successful computation. + +```csharp +public sealed record Ok(TSuccess Value) + : Result +``` + +### Error<TSuccess, TFailure> + +Represents a failed computation. + +```csharp +public sealed record Error(TFailure Value) + : Result +``` + +--- + +## Properties + +### IsOk + +Returns `true` if this result represents a successful computation. + +```csharp +public bool IsOk { get; } +``` + +### IsError + +Returns `true` if this result represents a failed computation. + +```csharp +public bool IsError { get; } +``` + +### Example + +```csharp +if (result.IsOk) +{ + // Handle success +} +else +{ + // Handle error +} +``` + +--- + +## Static Methods + +### Failure + +Creates a failed result containing the specified error. + +```csharp +public static Result Failure(TFailure error) +``` + +### Example + +```csharp +Result result = Result.Failure("User not found"); +``` + +--- + +## Instance Methods + +### Map<TNewSuccess> + +Transforms the success value using the provided function. If this is an Error, the error is propagated unchanged. + +```csharp +public Result Map( + Func mapper +) +``` + +This is the fundamental Functor operation for Result. + +### Example + +```csharp +Result numberResult = GetNumber(); // Returns Ok(42) +Result stringResult = numberResult.Map(n => n.ToString()); +// stringResult is Ok("42") + +Result errorResult = Result.Failure("Error"); +Result mappedError = errorResult.Map(n => n.ToString()); +// mappedError is Error("Error") - map function is not called +``` + +--- + +### MapError<TNewFailure> + +Transforms the error value using the provided function. If this is an Ok, the success value is propagated unchanged. + +```csharp +public Result MapError( + Func mapper +) +``` + +### Example + +```csharp +Result result = GetUser(); // Returns Error("Not found") +Result mappedResult = result.MapError(msg => new CustomError(msg)); +// mappedResult is Error(CustomError("Not found")) +``` + +--- + +### Bind<TNewSuccess> + +Monadic bind operation. Applies a function that returns a Result to the success value, flattening the nested Result. + +```csharp +public Result Bind( + Func> binder +) +``` + +This is the core operation that makes Result a monad, enabling Railway Oriented Programming. + +### Example + +```csharp +Result Validate(string input) +{ + return int.TryParse(input, out var number) + ? new Result.Ok(number) + : Result.Failure("Invalid number"); +} + +Result MultiplyByTwo(int n) +{ + return new Result.Ok(n * 2); +} + +Result result = Validate("21").Bind(MultiplyByTwo); +// result is Ok(42) + +Result errorResult = Validate("abc").Bind(MultiplyByTwo); +// errorResult is Error("Invalid number") - MultiplyByTwo is not called +``` + +--- + +### Match<TResult> + +Applies one of two functions based on the state of this Result. This is the catamorphism for Result. + +```csharp +public TResult Match( + Func onSuccess, + Func onError +) +``` + +### Example + +```csharp +string message = result.Match( + onSuccess: user => $"Hello, {user.Name}!", + onError: error => $"Error: {error}" +); +``` + +--- + +### Tap + +Performs a side effect based on the state without changing the Result. + +```csharp +public Result Tap( + Action? onSuccess = null, + Action? onError = null +) +``` + +Use this for logging, debugging, or other side effects while preserving the functional chain. + +### Example + +```csharp +var result = GetUser() + .Tap( + onSuccess: user => logger.LogInformation("Found user: {Name}", user.Name), + onError: error => logger.LogWarning("User lookup failed: {Error}", error) + ) + .Map(user => user.Email); +``` + +--- + +### GetValueOrDefault + +Returns the success value if Ok, otherwise returns the provided default. + +```csharp +public TSuccess GetValueOrDefault(TSuccess defaultValue) +public TSuccess GetValueOrDefault(Func defaultProvider) +``` + +### Example + +```csharp +string name = result.GetValueOrDefault("Unknown User"); + +// Lazy evaluation +string lazyName = result.GetValueOrDefault(() => ExpensiveDefaultCalculation()); +``` + +--- + +## Operators + +### ! (Logical Not) + +Extracts the error value if Error, throws if Ok. + +```csharp +public static TFailure operator !(Result result) +``` + +### Example + +```csharp +Result result = Result.Failure("Not found"); +string error = !result; // "Not found" +``` + +### + (Unary Plus) + +Extracts the success value if Ok, throws if Error. **Use only in tests.** + +```csharp +public static TSuccess operator +(Result result) +``` + +### Example + +```csharp +// In tests only! +Result result = GetUser(); +User user = +result; // Throws if Error +``` + +--- + +## Pattern Matching + +The recommended way to handle Results is with pattern matching: + +```csharp +var output = result switch +{ + Result.Ok(var user) + => $"Found: {user.Name}", + + Result.Error(var error) + => $"Error: {error.Message}", +}; +``` + +### With Type Aliases + +Define type aliases in `GlobalUsings.cs` for cleaner code: + +```csharp +global using OkUser = Outcome.Result + .Ok; +global using ErrorUser = Outcome.Result + .Error; +``` + +Then pattern match becomes: + +```csharp +var output = result switch +{ + OkUser(var user) => $"Found: {user.Name}", + ErrorUser(var error) => $"Error: {error.Message}", +}; +``` + +--- + +## Railway Oriented Programming + +Chain operations that may fail: + +```csharp +var result = await ValidateInput(input) + .Bind(ParseData) + .Bind(TransformData) + .Map(FormatOutput); + +// If any step fails, subsequent steps are skipped +// and the first error is propagated +``` + +--- + +## Best Practices + +1. **Prefer pattern matching** over `IsOk`/`IsError` checks +2. **Use `Bind`** for operations that return Results +3. **Use `Map`** for pure transformations +4. **Use `Tap`** for side effects (logging, metrics) +5. **Define type aliases** for frequently used Result types +6. **Never use `+` operator** outside of tests diff --git a/Website/src/docs/api/unit.md b/Website/src/docs/api/unit.md new file mode 100644 index 00000000..e144b2df --- /dev/null +++ b/Website/src/docs/api/unit.md @@ -0,0 +1,222 @@ +--- +layout: layouts/docs.njk +title: Unit Type +eleventyNavigation: + key: Unit Type + parent: API Reference + order: 8 +--- + +# Unit + +Represents a void return type or a type with exactly one value. Unit is the functional programming equivalent of `void`. + +**Namespace:** `Outcome` + +## Overview + +In functional programming, every function must return a value. When an operation does not have a meaningful return value (like deleting a resource), we use `Unit` instead of `void`. + +```csharp +public sealed record Unit +{ + public static Unit Value { get; } + private Unit() { } +} +``` + +## Why Unit? + +### The Problem with Void + +`void` cannot be used as a type parameter: + +```csharp +// This doesn't compile! +Result result = DeleteUser(id); +``` + +### The Solution: Unit + +```csharp +// This works! +Result result = DeleteUser(id); +``` + +--- + +## Properties + +### Value + +The single instance of Unit. + +```csharp +public static Unit Value { get; } +``` + +### Example + +```csharp +Unit unit = Unit.Value; +``` + +--- + +## Usage with Result + +### Delete Operations + +```csharp +public async Task>> DeleteUserAsync(int id) +{ + return await httpClient.DeleteAsync( + url: $"https://api.example.com/users/{id}".ToAbsoluteUrl(), + deserializeSuccess: async (response, ct) => Unit.Value, + deserializeError: DeserializeJson + ); +} + +// Usage +var result = await DeleteUserAsync(42); + +var message = result switch +{ + Result>.Ok>(_) + => "User deleted successfully", + Result>.Error>(var error) + => $"Failed to delete: {error}" +}; +``` + +### Download Operations + +```csharp +public Task>> DownloadFileAsync( + string url, + Stream destination +) => + httpClient.DownloadFileAsync( + url: url.ToAbsoluteUrl(), + destinationStream: destination, + deserializeError: DeserializeJson + ); +``` + +### Fire-and-Forget with Confirmation + +```csharp +public async Task>> SendNotificationAsync( + Notification notification +) +{ + return await httpClient.PostAsync( + url: "https://api.example.com/notifications".ToAbsoluteUrl(), + requestBody: SerializeJson(notification), + deserializeSuccess: async (response, ct) => Unit.Value, + deserializeError: DeserializeJson + ); +} +``` + +--- + +## Pattern Matching + +```csharp +var result = await DeleteUserAsync(42); + +result.Match( + onSuccess: _ => + { + Console.WriteLine("Deleted successfully"); + return Unit.Value; // Must return something + }, + onError: error => + { + Console.WriteLine($"Error: {error}"); + return Unit.Value; + } +); +``` + +### Ignoring the Unit Value + +```csharp +if (result.IsOk) +{ + Console.WriteLine("Success!"); +} + +// Or with pattern matching +if (result is Result>.Ok>) +{ + Console.WriteLine("Success!"); +} +``` + +--- + +## Creating Unit Results + +### Success + +```csharp +var success = new Result.Ok(Unit.Value); +``` + +### From Async Operations + +```csharp +async Task> DoSomethingAsync() +{ + try + { + await SomeOperation(); + return new Result.Ok(Unit.Value); + } + catch (Exception ex) + { + return Result.Failure(new Error(ex.Message)); + } +} +``` + +--- + +## Comparison with Void + +| Void | Unit | +|------|------| +| Cannot be used as type parameter | Can be used as type parameter | +| No value | Has exactly one value | +| `void Method()` | `Unit Method()` | +| Cannot be stored in variables | Can be stored in variables | +| Cannot be returned from lambdas in some contexts | Always returnable | + +--- + +## Functional Programming Context + +In functional programming languages like F# and Haskell, Unit (or `()`) is fundamental: + +```fsharp +// F# +let deleteUser id : Async> = ... +``` + +```haskell +-- Haskell +deleteUser :: Int -> IO (Either Error ()) +``` + +RestClient.Net brings this same pattern to C# for consistent functional programming. + +--- + +## Best Practices + +1. **Use `Unit.Value`** - Never create new Unit instances +2. **Prefer Unit over null** - Unit explicitly indicates "no meaningful value" +3. **Pattern match on success** - Use `_` to ignore the Unit value +4. **Keep return types consistent** - All HTTP operations return `Result` diff --git a/Website/src/docs/api/utilities.md b/Website/src/docs/api/utilities.md new file mode 100644 index 00000000..42856fb3 --- /dev/null +++ b/Website/src/docs/api/utilities.md @@ -0,0 +1,222 @@ +--- +layout: layouts/docs.njk +title: Utilities +eleventyNavigation: + key: Utilities + parent: API Reference + order: 4 +--- + +# Utilities + +Helper classes and utilities provided by RestClient.Net. + +**Namespace:** `RestClient.Net.Utilities` + +## ProgressReportingHttpContent + +An `HttpContent` implementation that reports progress during upload operations. + +```csharp +public class ProgressReportingHttpContent : HttpContent +``` + +### Purpose + +Use this class when you need to: +- Track upload progress for large files +- Display progress bars or indicators to users +- Monitor bandwidth usage + +### Constructors + +#### From String + +```csharp +public ProgressReportingHttpContent( + string data, + int bufferSize = 8192, + Action? progress = null, + string contentType = "application/octet-stream" +) +``` + +#### From Byte Array + +```csharp +public ProgressReportingHttpContent( + byte[] data, + int bufferSize = 8192, + Action? progress = null, + string contentType = "application/octet-stream" +) +``` + +#### From Stream + +```csharp +public ProgressReportingHttpContent( + Stream content, + int bufferSize = 8192, + string contentType = "application/octet-stream", + Action? progress = null +) +``` + +### Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `data`/`content` | `string`/`byte[]`/`Stream` | - | The content to upload | +| `bufferSize` | `int` | 8192 | Buffer size for reading content | +| `progress` | `Action?` | null | Callback receiving (bytesTransferred, totalBytes) | +| `contentType` | `string` | "application/octet-stream" | MIME type of the content | + +### Progress Callback + +The progress callback receives two parameters: +1. `bytesTransferred` - Number of bytes sent so far +2. `totalBytes` - Total number of bytes to send + +### Example: File Upload with Progress + +```csharp +using var fileStream = File.OpenRead("large-file.zip"); + +var content = new ProgressReportingHttpContent( + content: fileStream, + bufferSize: 65536, // 64KB buffer for better performance + contentType: "application/zip", + progress: (current, total) => + { + var percentage = (double)current / total * 100; + Console.WriteLine($"Upload progress: {percentage:F1}% ({current}/{total} bytes)"); + } +); + +var result = await httpClient.UploadFileAsync( + url: "https://api.example.com/upload".ToAbsoluteUrl(), + requestBody: content, + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson +); +``` + +### Example: Progress Bar in Console + +```csharp +var content = new ProgressReportingHttpContent( + content: fileStream, + progress: (current, total) => + { + var percentage = (int)((double)current / total * 100); + var progressBar = new string('#', percentage / 2) + new string('-', 50 - percentage / 2); + Console.Write($"\r[{progressBar}] {percentage}%"); + } +); +``` + +### Example: Progress with Blazor + +```csharp +@code { + private double _uploadProgress; + + private async Task UploadFileAsync(IBrowserFile file) + { + using var stream = file.OpenReadStream(maxAllowedSize: 100 * 1024 * 1024); + + var content = new ProgressReportingHttpContent( + content: stream, + contentType: file.ContentType, + progress: (current, total) => + { + _uploadProgress = (double)current / total * 100; + InvokeAsync(StateHasChanged); + } + ); + + var result = await Http.UploadFileAsync( + url: "https://api.example.com/upload".ToAbsoluteUrl(), + requestBody: content, + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson + ); + } +} + + +``` + +### Methods + +#### SerializeToStreamAsync + +```csharp +protected override async Task SerializeToStreamAsync( + Stream stream, + TransportContext? context +) +``` + +Writes the content to the stream while reporting progress. + +#### TryComputeLength + +```csharp +protected override bool TryComputeLength(out long length) +``` + +Returns the content length. Always returns `true` with the actual length. + +#### Dispose + +```csharp +public new void Dispose() +``` + +Disposes the underlying stream and base content. + +--- + +## Best Practices + +### Buffer Size Selection + +| File Size | Recommended Buffer Size | +|-----------|------------------------| +| < 1 MB | 8192 (8 KB) - default | +| 1-10 MB | 32768 (32 KB) | +| 10-100 MB | 65536 (64 KB) | +| > 100 MB | 131072 (128 KB) | + +Larger buffers reduce the number of I/O operations but use more memory. + +### Progress Update Throttling + +For very large files, you may want to throttle progress updates: + +```csharp +var lastUpdate = DateTime.MinValue; + +var content = new ProgressReportingHttpContent( + content: fileStream, + progress: (current, total) => + { + var now = DateTime.UtcNow; + if ((now - lastUpdate).TotalMilliseconds > 100) // Update every 100ms max + { + lastUpdate = now; + UpdateProgressUI(current, total); + } + } +); +``` + +### Memory Considerations + +- For string content: The entire string is loaded into memory +- For byte array content: The entire array is loaded into memory +- For stream content: Only the buffer size is kept in memory at a time + +Use stream-based constructors for large files to minimize memory usage. diff --git a/Website/src/docs/basic-usage.md b/Website/src/docs/basic-usage.md new file mode 100644 index 00000000..dde23f09 --- /dev/null +++ b/Website/src/docs/basic-usage.md @@ -0,0 +1,259 @@ +--- +layout: layouts/docs.njk +title: Basic Usage +description: Learn the fundamentals of making type-safe REST calls with RestClient.Net. GET, POST, PUT, DELETE examples with proper error handling. +keywords: RestClient.Net tutorial, C# REST client, HttpClient extensions, type-safe HTTP calls +eleventyNavigation: + key: Basic Usage + order: 3 +faq: + - question: How do I make a GET request with RestClient.Net? + answer: Use the GetAsync extension method on HttpClient with a URL, success deserializer, and error deserializer. The result is a discriminated union you must pattern match. + - question: What is the ToAbsoluteUrl() extension? + answer: ToAbsoluteUrl() converts a string to an AbsoluteUrl type, which validates the URL format at compile time and provides type safety. + - question: Why do I need deserializer functions? + answer: Deserializer functions allow you to control exactly how responses are parsed, supporting any serialization format (JSON, XML, etc.) and custom validation. +--- + +# Basic Usage + +This guide covers the fundamentals of making REST calls with RestClient.Net. + +## Your First Request + +Let's make a simple GET request to fetch a post: + +```csharp +using System.Net.Http.Json; +using RestClient.Net; +using Urls; + +// Define your models +record Post(int UserId, int Id, string Title, string Body); +record ErrorResponse(string Message); + +// Create HttpClient (use IHttpClientFactory in production) +using var httpClient = new HttpClient(); + +// Make the request +var result = await httpClient.GetAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Null response"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("Unknown error") +); + +// Handle the result - ALL cases must be handled +var message = result switch +{ + Outcome.Result>.Ok(var post) => + $"Success: {post.Title}", + + Outcome.Result>.Error( + Outcome.HttpError.ResponseError(var err, var status, _)) => + $"API Error {status}: {err.Message}", + + Outcome.Result>.Error( + Outcome.HttpError.ExceptionError(var ex)) => + $"Exception: {ex.Message}", +}; + +Console.WriteLine(message); +``` + +## Using Type Aliases + +The full type names are verbose. Add type aliases to `GlobalUsings.cs`: + +```csharp +// GlobalUsings.cs +global using OkPost = Outcome.Result> + .Ok>; + +global using ErrorPost = Outcome.Result> + .Error>; + +global using ResponseErrorPost = Outcome.HttpError.ErrorResponseError; + +global using ExceptionErrorPost = Outcome.HttpError.ExceptionError; +``` + +Now pattern matching is cleaner: + +```csharp +var message = result switch +{ + OkPost(var post) => $"Success: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"API Error {status}: {err.Message}", + ErrorPost(ExceptionErrorPost(var ex)) => $"Exception: {ex.Message}", +}; +``` + +## POST Request + +Create a new resource with POST: + +```csharp +record CreatePostRequest(string Title, string Body, int UserId); + +var newPost = new CreatePostRequest("My Title", "My content", 1); + +var result = await httpClient.PostAsync( + url: "https://jsonplaceholder.typicode.com/posts".ToAbsoluteUrl(), + body: newPost, + serializeRequest: body => JsonContent.Create(body), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Null response"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("Unknown error") +); +``` + +## PUT Request + +Replace an existing resource: + +```csharp +record UpdatePostRequest(int Id, string Title, string Body, int UserId); + +var updatedPost = new UpdatePostRequest(1, "Updated Title", "Updated content", 1); + +var result = await httpClient.PutAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + body: updatedPost, + serializeRequest: body => JsonContent.Create(body), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Null response"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("Unknown error") +); +``` + +## DELETE Request + +Remove a resource: + +```csharp +record DeleteResponse(bool Success); + +var result = await httpClient.DeleteAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: async (content, ct) => + new DeleteResponse(true), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("Unknown error") +); +``` + +## PATCH Request + +Partially update a resource: + +```csharp +record PatchPostRequest(string? Title = null, string? Body = null); + +var patch = new PatchPostRequest(Title: "Only updating title"); + +var result = await httpClient.PatchAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + body: patch, + serializeRequest: body => JsonContent.Create(body), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Null response"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("Unknown error") +); +``` + +## Reusable Deserializers + +Create reusable deserializer methods: + +```csharp +public static class Deserializers +{ + public static async Task Json(HttpContent content, CancellationToken ct) + where T : class => + await content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException($"Failed to deserialize {typeof(T).Name}"); + + public static async Task Error(HttpContent content, CancellationToken ct) => + await content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("Unknown error"); +} + +// Usage +var result = await httpClient.GetAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error +); +``` + +## Adding Headers + +Set headers on the HttpClient: + +```csharp +httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "your-token"); + +httpClient.DefaultRequestHeaders.Add("X-Custom-Header", "value"); + +var result = await httpClient.GetAsync(...); +``` + +## Query Parameters + +Build URLs with query parameters: + +```csharp +// Using string interpolation +var url = $"https://api.example.com/posts?userId={userId}&page={page}".ToAbsoluteUrl(); + +// Or use a URL builder library +var result = await httpClient.GetAsync( + url: url, + deserializeSuccess: Deserializers.Json>, + deserializeError: Deserializers.Error +); +``` + +## Cancellation + +Pass a `CancellationToken` for cancellable requests: + +```csharp +var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + +var result = await httpClient.GetAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error, + cancellationToken: cts.Token +); +``` + +## Best Practices + +1. **Use IHttpClientFactory** in production for proper connection pooling +2. **Define type aliases** in GlobalUsings.cs for cleaner code +3. **Create reusable deserializers** to avoid duplication +4. **Always handle all cases** in switch expressions +5. **Use cancellation tokens** for user-facing requests + +## Next Steps + +- [Error Handling](/docs/error-handling/) - Deep dive into Result types +- [Advanced Usage](/docs/advanced-usage/) - Retry policies, middleware, and more +- [API Reference](/api/) - Complete method documentation diff --git a/Website/src/docs/error-handling.md b/Website/src/docs/error-handling.md new file mode 100644 index 00000000..b6cf927e --- /dev/null +++ b/Website/src/docs/error-handling.md @@ -0,0 +1,360 @@ +--- +layout: layouts/docs.njk +title: Error Handling +description: Master functional error handling with RestClient.Net Result types. Learn pattern matching, discriminated unions, and exhaustiveness checking for C# REST calls. +keywords: C# error handling, Result type, discriminated unions, pattern matching, exhaustiveness checking +eleventyNavigation: + key: Error Handling + order: 4 +faq: + - question: What is a Result type in RestClient.Net? + answer: A Result type is a discriminated union that represents either success (Ok) or failure (Error). It forces you to handle all possible outcomes at compile time. + - question: What is exhaustiveness checking? + answer: The Exhaustion analyzer verifies that your switch expressions handle all possible cases. Missing a case causes a compile error, not a runtime crash. + - question: What is the difference between ResponseError and ExceptionError? + answer: ResponseError occurs when the server returns an error status code (4xx, 5xx) with a response body. ExceptionError occurs when an exception is thrown (network error, timeout, etc.). +--- + +# Error Handling + +RestClient.Net uses discriminated unions and exhaustiveness checking to make error handling safe and explicit. This guide explains the patterns and best practices. + +## The Problem with Traditional Error Handling + +Traditional HTTP clients throw exceptions: + +```csharp +// Traditional approach - dangerous! +try +{ + var response = await httpClient.GetAsync("https://api.example.com/user/1"); + response.EnsureSuccessStatusCode(); // Throws on error! + var user = await response.Content.ReadFromJsonAsync(); +} +catch (HttpRequestException ex) +{ + // Network error +} +catch (JsonException ex) +{ + // Deserialization error +} +catch (Exception ex) +{ + // What else could happen? Who knows! +} +``` + +Problems: +- Nothing in the type signature tells you what might throw +- Easy to forget a catch block +- The happy path and error path have different structures +- Runtime crashes if you miss an exception type + +## The RestClient.Net Approach + +Every operation returns a `Result>`: + +```csharp +// The type tells you everything that can happen +Result> result = await httpClient.GetAsync(...); + +// Pattern matching ensures you handle all cases +var output = result switch +{ + OkUser(var user) => user.Name, + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"API Error {status}", + ErrorUser(ExceptionErrorUser(var ex)) => $"Exception: {ex.Message}", +}; +``` + +## Understanding the Result Type + +### Result + +The base discriminated union: + +```csharp +public abstract record Result +{ + public sealed record Ok(TSuccess Value) : Result; + public sealed record Error(TError Value) : Result; +} +``` + +### HttpError + +The error type for HTTP operations: + +```csharp +public abstract record HttpError +{ + // Server returned an error response (4xx, 5xx) + public sealed record ResponseError( + TError Error, + HttpStatusCode StatusCode, + HttpResponseMessage Response + ) : HttpError; + + // Exception occurred (network error, timeout, etc.) + public sealed record ExceptionError( + Exception Exception + ) : HttpError; +} +``` + +## Exhaustiveness Checking + +The Exhaustion analyzer ensures you handle all cases: + +```csharp +// This won't compile! +var message = result switch +{ + OkUser(var user) => "Success", + ErrorUser(ResponseErrorUser(...)) => "Response Error", + // COMPILE ERROR: Missing ExceptionErrorUser +}; +``` + +The error message tells you exactly what's missing: + +``` +error EXHAUSTION001: Switch on Result is not exhaustive; +Matched: Ok>, Error> with ErrorResponseError +Missing: Error> with ExceptionError +``` + +## Pattern Matching Patterns + +### Basic Pattern + +Handle all three cases explicitly: + +```csharp +var message = result switch +{ + OkUser(var user) => $"Welcome, {user.Name}", + ErrorUser(ResponseErrorUser(var err, var status, _)) => + $"Server error {(int)status}: {err.Message}", + ErrorUser(ExceptionErrorUser(var ex)) => + $"Network error: {ex.Message}", +}; +``` + +### With Status Code Matching + +Handle specific status codes differently: + +```csharp +var message = result switch +{ + OkUser(var user) => $"Found: {user.Name}", + + ErrorUser(ResponseErrorUser(_, HttpStatusCode.NotFound, _)) => + "User not found", + + ErrorUser(ResponseErrorUser(_, HttpStatusCode.Unauthorized, _)) => + "Please log in", + + ErrorUser(ResponseErrorUser(var err, var status, _)) => + $"Server error {(int)status}: {err.Message}", + + ErrorUser(ExceptionErrorUser(var ex)) => + $"Network error: {ex.Message}", +}; +``` + +### Accessing the Full Response + +The `ResponseError` includes the full `HttpResponseMessage`: + +```csharp +var message = result switch +{ + OkUser(var user) => user.Name, + + ErrorUser(ResponseErrorUser(var err, var status, var response)) => + { + // Access response headers + if (response.Headers.TryGetValues("X-Rate-Limit-Remaining", out var values)) + { + Console.WriteLine($"Rate limit remaining: {values.First()}"); + } + return $"Error: {err.Message}"; + }, + + ErrorUser(ExceptionErrorUser(var ex)) => $"Exception: {ex.Message}", +}; +``` + +### Exception Type Matching + +Handle specific exception types: + +```csharp +var message = result switch +{ + OkUser(var user) => user.Name, + + ErrorUser(ResponseErrorUser(var err, _, _)) => err.Message, + + ErrorUser(ExceptionErrorUser(TaskCanceledException ex)) when ex.CancellationToken.IsCancellationRequested => + "Request was cancelled", + + ErrorUser(ExceptionErrorUser(TaskCanceledException)) => + "Request timed out", + + ErrorUser(ExceptionErrorUser(HttpRequestException)) => + "Network connectivity issue", + + ErrorUser(ExceptionErrorUser(var ex)) => + $"Unexpected error: {ex.Message}", +}; +``` + +## Common Error Handling Patterns + +### Default Value on Error + +Return a default when any error occurs: + +```csharp +User user = result switch +{ + OkUser(var u) => u, + _ => User.Guest, +}; +``` + +### Throw on Error (Escape Hatch) + +When you genuinely can't handle the error: + +```csharp +User user = result switch +{ + OkUser(var u) => u, + ErrorUser(var error) => throw new InvalidOperationException($"Failed to get user: {error}"), +}; +``` + +### Convert to Nullable + +Useful for optional data: + +```csharp +User? user = result switch +{ + OkUser(var u) => u, + _ => null, +}; + +if (user is not null) +{ + // Use user +} +``` + +### Logging Errors + +Log errors while still handling them: + +```csharp +var message = result switch +{ + OkUser(var user) => user.Name, + + ErrorUser(ResponseErrorUser(var err, var status, _)) => + { + logger.LogWarning("API returned {Status}: {Error}", status, err.Message); + return "Service temporarily unavailable"; + }, + + ErrorUser(ExceptionErrorUser(var ex)) => + { + logger.LogError(ex, "Network error occurred"); + return "Connection failed"; + }, +}; +``` + +## Chaining Operations + +### Map - Transform Success + +Transform the success value without touching errors: + +```csharp +// Convert Result> to Result> +var nameResult = userResult.Map(user => user.Name); +``` + +### FlatMap / Bind - Chain Async Operations + +Chain operations that each return a Result: + +```csharp +// Get user, then get their orders +var ordersResult = await userResult + .FlatMapAsync(user => GetOrdersAsync(user.Id)); +``` + +### Aggregate Multiple Results + +Combine multiple Results: + +```csharp +var userResult = await GetUserAsync(userId); +var ordersResult = await GetOrdersAsync(userId); +var settingsResult = await GetSettingsAsync(userId); + +var combined = (userResult, ordersResult, settingsResult) switch +{ + (OkUser(var user), OkOrders(var orders), OkSettings(var settings)) => + new UserDashboard(user, orders, settings), + + (ErrorUser(var e), _, _) => throw new Exception($"User error: {e}"), + (_, ErrorOrders(var e), _) => throw new Exception($"Orders error: {e}"), + (_, _, ErrorSettings(var e)) => throw new Exception($"Settings error: {e}"), +}; +``` + +## Error Response Models + +Define clear error models for your API: + +```csharp +// Simple error +record ApiError(string Message, string Code); + +// Detailed error with validation +record ValidationError( + string Message, + Dictionary Errors +); + +// Standard problem details (RFC 7807) +record ProblemDetails( + string Type, + string Title, + int Status, + string Detail, + string Instance +); +``` + +## Best Practices + +1. **Define type aliases** for cleaner pattern matching +2. **Always handle all cases** - the compiler enforces this +3. **Be specific with error handling** - don't just catch everything +4. **Log errors** before returning user-friendly messages +5. **Use the response object** for headers and advanced scenarios +6. **Chain operations** with Map and FlatMap when appropriate + +## Next Steps + +- [Advanced Usage](/docs/advanced-usage/) - Retry policies and middleware +- [Exhaustion Analyzer](/docs/exhaustion/) - Deep dive into exhaustiveness checking +- [API Reference](/api/result-types/) - Complete Result type documentation diff --git a/Website/src/docs/exhaustion.md b/Website/src/docs/exhaustion.md new file mode 100644 index 00000000..131af419 --- /dev/null +++ b/Website/src/docs/exhaustion.md @@ -0,0 +1,265 @@ +--- +layout: layouts/docs.njk +title: Exhaustion Analyzer +description: Roslyn analyzer for compile-time exhaustiveness checking in C# switch expressions. Catch missing cases before runtime with the Exhaustion analyzer. +keywords: exhaustiveness checking, C# analyzer, Roslyn analyzer, switch expression, pattern matching +eleventyNavigation: + key: Exhaustion + order: 8 +faq: + - question: What is exhaustiveness checking? + answer: Exhaustiveness checking verifies that a switch expression handles all possible cases of a discriminated union, turning runtime crashes into compile-time errors. + - question: Is Exhaustion included with RestClient.Net? + answer: Yes, the Exhaustion analyzer is automatically included when you install RestClient.Net. You can also install it separately for other use cases. + - question: What error code does Exhaustion use? + answer: The analyzer uses EXHAUSTION001 for non-exhaustive switch expressions, providing details about which cases are matched and which are missing. +--- + +# Exhaustion Analyzer + +The Exhaustion analyzer is a Roslyn analyzer that ensures your switch expressions handle all possible cases of discriminated unions at compile time. + +## The Problem + +C#'s default switch exhaustiveness checking is limited. This code compiles but crashes at runtime: + +```csharp +// This compiles without warnings! +var output = result switch +{ + OkPost(var post) => $"Success: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"Error {status}", + // Missing ExceptionErrorPost case - runtime crash! +}; +``` + +When an `ExceptionError` occurs, you get a `MatchException` at runtime. In production. On a Friday afternoon. + +## The Solution + +With Exhaustion installed, the same code produces a compile error: + +``` +error EXHAUSTION001: Switch on Result is not exhaustive; +Matched: Ok>, Error> with ErrorResponseError +Missing: Error> with ExceptionError +``` + +**Runtime crashes become compile-time errors.** + +## Installation + +### With RestClient.Net + +Exhaustion is automatically included: + +```bash +dotnet add package RestClient.Net +``` + +### Standalone + +For use with your own discriminated unions: + +```bash +dotnet add package Exhaustion +``` + +## How It Works + +Exhaustion uses Roslyn's analyzer API to: + +1. Detect switch expressions on Result types +2. Analyze which patterns are matched +3. Determine which cases are missing +4. Report compile-time errors for incomplete switches + +### Supported Types + +Exhaustion works with: + +- `Result` from RestClient.Net +- `HttpError` from RestClient.Net +- Custom discriminated unions following the same pattern + +## Error Messages + +### EXHAUSTION001: Non-exhaustive switch + +``` +error EXHAUSTION001: Switch on Result is not exhaustive; +Matched: Ok> +Missing: Error> with ErrorResponseError, Error> with ExceptionError +``` + +The error tells you: +- What type the switch is on +- Which cases ARE matched +- Which cases are MISSING + +## Examples + +### Missing HttpError Cases + +```csharp +// ERROR: Missing both error cases +var message = result switch +{ + OkUser(var user) => user.Name, +}; + +// ERROR: Missing ExceptionError +var message = result switch +{ + OkUser(var user) => user.Name, + ErrorUser(ResponseErrorUser(var err, _, _)) => err.Message, +}; + +// CORRECT: All cases handled +var message = result switch +{ + OkUser(var user) => user.Name, + ErrorUser(ResponseErrorUser(var err, _, _)) => err.Message, + ErrorUser(ExceptionErrorUser(var ex)) => ex.Message, +}; +``` + +### Using Discard Pattern + +You can use discard for cases you want to handle uniformly: + +```csharp +// CORRECT: Discard covers remaining cases +var message = result switch +{ + OkUser(var user) => user.Name, + _ => "Error occurred", +}; +``` + +However, this defeats the purpose of exhaustiveness checking. Use it sparingly. + +### Multiple Result Types + +Each Result type needs its own handling: + +```csharp +var (userResult, ordersResult) = await (GetUserAsync(), GetOrdersAsync()); + +// Both must be exhaustively handled +var userMessage = userResult switch +{ + OkUser(var user) => user.Name, + ErrorUser(ResponseErrorUser(var err, _, _)) => err.Message, + ErrorUser(ExceptionErrorUser(var ex)) => ex.Message, +}; + +var ordersMessage = ordersResult switch +{ + OkOrders(var orders) => $"{orders.Count} orders", + ErrorOrders(ResponseErrorOrders(var err, _, _)) => err.Message, + ErrorOrders(ExceptionErrorOrders(var ex)) => ex.Message, +}; +``` + +## Creating Exhaustive Custom Types + +You can use Exhaustion with your own discriminated unions: + +```csharp +// Define a discriminated union +public abstract record PaymentResult +{ + public sealed record Success(string TransactionId) : PaymentResult; + public sealed record Declined(string Reason) : PaymentResult; + public sealed record Error(Exception Exception) : PaymentResult; +} + +// Exhaustion will check switches on PaymentResult +var message = paymentResult switch +{ + PaymentResult.Success(var txId) => $"Paid: {txId}", + PaymentResult.Declined(var reason) => $"Declined: {reason}", + PaymentResult.Error(var ex) => $"Error: {ex.Message}", +}; +``` + +## Configuration + +### Suppress for Specific Lines + +In rare cases, suppress the warning: + +```csharp +#pragma warning disable EXHAUSTION001 +var message = result switch +{ + OkUser(var user) => user.Name, + // Intentionally incomplete +}; +#pragma warning restore EXHAUSTION001 +``` + +### Global Suppression + +In `.editorconfig` (not recommended): + +```ini +[*.cs] +dotnet_diagnostic.EXHAUSTION001.severity = none +``` + +## IDE Integration + +### Visual Studio + +Exhaustion errors appear in the Error List with full details. Click the error to navigate to the problematic switch. + +### VS Code + +With the C# extension, errors appear in the Problems panel and as red squiggles in the editor. + +### Rider + +JetBrains Rider fully supports Roslyn analyzers. Errors appear in the inspection results. + +## Benefits + +1. **Catch errors early** - Compile time, not runtime +2. **Safer refactoring** - Add a new case? Compiler finds all switches +3. **Self-documenting** - The types show all possible outcomes +4. **Better code review** - Reviewers don't need to check for missing cases + +## Best Practices + +1. **Never suppress without a comment** explaining why +2. **Avoid discard patterns** (`_`) unless intentional +3. **Handle each case explicitly** for clarity +4. **Use type aliases** for cleaner pattern matching +5. **Let the analyzer guide you** when types change + +## Troubleshooting + +### Analyzer Not Working + +Ensure the package is installed: + +```bash +dotnet list package | grep Exhaustion +``` + +Rebuild the project: + +```bash +dotnet build --no-incremental +``` + +### False Positives + +If Exhaustion reports an error incorrectly, please [report an issue](https://github.com/MelbourneDeveloper/RestClient.Net/issues). + +## Next Steps + +- [Error Handling](/docs/error-handling/) - Result type patterns +- [Basic Usage](/docs/basic-usage/) - Getting started guide +- [API Reference](/api/result-types/) - Result type documentation diff --git a/Website/src/docs/index.md b/Website/src/docs/index.md new file mode 100644 index 00000000..d96e1d29 --- /dev/null +++ b/Website/src/docs/index.md @@ -0,0 +1,198 @@ +--- +layout: layouts/docs.njk +title: Getting Started +description: Get started with RestClient.Net, a type-safe REST client for C# with functional error handling, discriminated unions, and compile-time exhaustiveness checking. +keywords: RestClient.Net, C# REST client, type-safe HTTP, functional error handling, discriminated unions +eleventyNavigation: + key: Getting Started + order: 1 +faq: + - question: What is RestClient.Net? + answer: RestClient.Net is a type-safe REST client for C# that uses discriminated unions and pattern matching for error handling, eliminating the need for try-catch blocks around HTTP calls. + - question: Why use RestClient.Net instead of HttpClient directly? + answer: RestClient.Net wraps HttpClient with Result types that force you to handle all error cases at compile time. This eliminates runtime crashes from unhandled exceptions. + - question: What is the Exhaustion analyzer? + answer: The Exhaustion analyzer is a Roslyn analyzer that verifies your switch expressions handle all possible cases of a Result type. Missing a case causes a compile error instead of a runtime crash. +--- + + + +# Getting Started with RestClient.Net + +RestClient.Net is a type-safe REST client for C# that brings functional programming patterns to HTTP communication. Instead of throwing exceptions, every call returns a `Result` type that you must explicitly handle. + +## Why RestClient.Net? + +Traditional HTTP clients throw exceptions, which have problems: + +- **Invisible errors** - Method signatures don't show what can go wrong +- **Easy to forget** - Miss a catch block and your app crashes +- **Inconsistent handling** - Try-catch scattered throughout the codebase + +RestClient.Net solves this with **discriminated unions**: + +```csharp +// The return type tells you everything that can happen +Result> result = await httpClient.GetUserAsync(...); +``` + +## Quick Start + +### 1. Install the Package + +```bash +dotnet add package RestClient.Net +``` + +This includes: +- `HttpClient` extension methods +- `Result>` types +- The **Exhaustion analyzer** for compile-time checking + +### 2. Define Your Models + +```csharp +// Success response +record Post(int UserId, int Id, string Title, string Body); + +// Error response +record ApiError(string Message, string? Code = null); +``` + +### 3. Make a Request + +```csharp +using System.Net.Http.Json; +using RestClient.Net; +using Urls; + +using var httpClient = new HttpClient(); + +var result = await httpClient.GetAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("Null response"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? new ApiError("Unknown error") +); +``` + +### 4. Handle the Result + +Pattern match on the result to handle all cases: + +```csharp +var message = result switch +{ + Outcome.Result>.Ok(var post) => + $"Success: {post.Title}", + + Outcome.Result>.Error( + Outcome.HttpError.ResponseError(var err, var status, _)) => + $"API Error {status}: {err.Message}", + + Outcome.Result>.Error( + Outcome.HttpError.ExceptionError(var ex)) => + $"Exception: {ex.Message}", +}; + +Console.WriteLine(message); +``` + +### 5. Add Type Aliases (Recommended) + +The full type names are verbose. Add type aliases to `GlobalUsings.cs`: + +```csharp +global using OkPost = Outcome.Result> + .Ok>; + +global using ErrorPost = Outcome.Result> + .Error>; + +global using ResponseErrorPost = Outcome.HttpError.ErrorResponseError; + +global using ExceptionErrorPost = Outcome.HttpError.ExceptionError; +``` + +Now pattern matching is cleaner: + +```csharp +var message = result switch +{ + OkPost(var post) => $"Success: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"API Error {status}: {err.Message}", + ErrorPost(ExceptionErrorPost(var ex)) => $"Exception: {ex.Message}", +}; +``` + +## Exhaustiveness Checking + +The Exhaustion analyzer ensures you don't miss any cases: + +```csharp +// This won't compile! +var message = result switch +{ + OkPost(var post) => "Success", + ErrorPost(ResponseErrorPost(...)) => "API Error", + // COMPILE ERROR: Missing ExceptionErrorPost case! +}; +``` + +``` +error EXHAUSTION001: Switch on Result is not exhaustive; +Missing: Error> with ExceptionError +``` + +**Runtime crashes become compile-time errors.** + +## What's in the Box + +| Package | Description | +|---------|-------------| +| `RestClient.Net` | Core library with HttpClient extensions and Result types | +| `RestClient.Net.OpenApiGenerator` | Generate type-safe clients from OpenAPI 3.x specs | +| `RestClient.Net.McpGenerator` | Generate MCP servers for Claude Code integration | +| `Exhaustion` | Roslyn analyzer for switch exhaustiveness (included) | + +## Next Steps + +- [Installation](/docs/installation/) - Detailed setup instructions +- [Basic Usage](/docs/basic-usage/) - GET, POST, PUT, DELETE examples +- [Error Handling](/docs/error-handling/) - Deep dive into Result types +- [Advanced Usage](/docs/advanced-usage/) - Retry policies, authentication, caching +- [OpenAPI Generator](/docs/openapi/) - Generate clients from specs +- [Code Examples](/examples/) - Complete working examples diff --git a/Website/src/docs/installation.md b/Website/src/docs/installation.md new file mode 100644 index 00000000..6cefc42f --- /dev/null +++ b/Website/src/docs/installation.md @@ -0,0 +1,179 @@ +--- +layout: layouts/docs.njk +title: Installation +description: Install RestClient.Net and related packages for type-safe REST calls in C#. NuGet packages for the core library, OpenAPI generator, MCP server, and exhaustiveness analyzer. +keywords: RestClient.Net installation, NuGet package, dotnet add package, C# REST client setup +eleventyNavigation: + key: Installation + order: 2 +faq: + - question: How do I install RestClient.Net? + answer: Run 'dotnet add package RestClient.Net' in your project directory to install the core library from NuGet. + - question: What .NET versions does RestClient.Net support? + answer: RestClient.Net requires .NET 8.0 or higher, taking advantage of the latest C# language features for pattern matching. + - question: Do I need to install the Exhaustion analyzer separately? + answer: The Exhaustion analyzer is included automatically when you install RestClient.Net. You can also install it separately with 'dotnet add package Exhaustion'. +--- + + + +# Installation + +RestClient.Net is distributed as NuGet packages. This guide covers installing all available packages. + +## Prerequisites + +- **.NET 8.0** or higher +- A code editor (Visual Studio, VS Code, or Rider recommended) +- Basic familiarity with C# and `HttpClient` + +## Core Package + +The main package provides `HttpClient` extension methods with Result types: + +```bash +dotnet add package RestClient.Net +``` + +This automatically includes: +- `HttpClient` extension methods (`GetAsync`, `PostAsync`, etc.) +- `Result>` types +- `AbsoluteUrl` type and extensions +- **Exhaustion analyzer** for compile-time exhaustiveness checking + +## Package Reference (csproj) + +Alternatively, add directly to your `.csproj`: + +```xml + + + +``` + +## Additional Packages + +### OpenAPI Generator + +Generate type-safe clients from OpenAPI 3.x specifications: + +```bash +dotnet add package RestClient.Net.OpenApiGenerator +``` + +### MCP Server Generator + +Generate Model Context Protocol servers for Claude Code integration: + +```bash +dotnet add package RestClient.Net.McpGenerator +``` + +### Standalone Exhaustion Analyzer + +If you only want the exhaustiveness analyzer without the REST client: + +```bash +dotnet add package Exhaustion +``` + +## Quick Setup + +After installing, create a `GlobalUsings.cs` file in your project root: + +```csharp +// GlobalUsings.cs +global using System.Net.Http.Json; +global using System.Text.Json; +global using RestClient.Net; +global using Urls; +``` + +For each model type you use, add type aliases for cleaner pattern matching: + +```csharp +// Example for a User model with ApiError +global using OkUser = Outcome.Result> + .Ok>; + +global using ErrorUser = Outcome.Result> + .Error>; + +global using ResponseErrorUser = Outcome.HttpError.ErrorResponseError; + +global using ExceptionErrorUser = Outcome.HttpError.ExceptionError; +``` + +## Verify Installation + +Create a simple test to verify everything is working: + +```csharp +using System.Text.Json; +using RestClient.Net; +using Urls; + +// Define models +record Post(int UserId, int Id, string Title, string Body); +record ErrorResponse(string Message); + +// Create HttpClient +using var httpClient = new HttpClient(); + +// Make a test call +var result = await httpClient.GetAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) ?? throw new Exception("Null response"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) ?? new ErrorResponse("Unknown error") +); + +Console.WriteLine(result switch +{ + Outcome.Result>.Ok(var post) => + $"Success: {post.Title}", + Outcome.Result>.Error(var error) => + $"Error: {error}", +}); +``` + +## IDE Support + +### Visual Studio + +The Exhaustion analyzer works out of the box. You'll see compile errors for non-exhaustive switch expressions. + +### VS Code + +Install the C# extension (ms-dotnettools.csharp) for full analyzer support. + +### Rider + +JetBrains Rider fully supports Roslyn analyzers, including Exhaustion. + +## Next Steps + +- [Basic Usage](/docs/basic-usage/) - Learn the fundamentals +- [Error Handling](/docs/error-handling/) - Master Result types +- [OpenAPI Generator](/docs/openapi/) - Generate clients from specs diff --git a/Website/src/docs/mcp.md b/Website/src/docs/mcp.md new file mode 100644 index 00000000..10730332 --- /dev/null +++ b/Website/src/docs/mcp.md @@ -0,0 +1,299 @@ +--- +layout: layouts/docs.njk +title: MCP Server +description: Generate Model Context Protocol servers for Claude Code integration. Turn your REST APIs into AI-accessible tools with automatic type-safe bindings. +keywords: MCP server, Model Context Protocol, Claude Code, AI tools, REST to MCP +eleventyNavigation: + key: MCP Server + order: 7 +faq: + - question: What is the Model Context Protocol (MCP)? + answer: MCP is a protocol that allows AI assistants like Claude to interact with external tools and data sources. RestClient.Net can generate MCP servers from your OpenAPI specs. + - question: How does the MCP generator work? + answer: It reads your OpenAPI specification and generates C# code that exposes each API operation as an MCP tool that Claude Code can call. + - question: Can I filter which operations become MCP tools? + answer: Yes, use the --tags option to select specific operation tags, or --exclude-tags to skip certain operations. +--- + +# MCP Server Generation + +Generate Model Context Protocol (MCP) servers from OpenAPI specifications, enabling Claude Code to interact with your REST APIs. + +## What is MCP? + +The Model Context Protocol allows AI assistants to: +- Call external APIs as "tools" +- Access data sources +- Perform actions on behalf of users + +With RestClient.Net's MCP generator, your REST APIs become Claude Code tools automatically. + +## Prerequisites + +1. An OpenAPI 3.x specification +2. Generated RestClient.Net client (see [OpenAPI Generator](/docs/openapi/)) +3. RestClient.Net.McpGenerator.Cli tool + +## Installation + +Install the MCP generator CLI: + +```bash +dotnet tool install -g RestClient.Net.McpGenerator.Cli +``` + +## Quick Start + +### Step 1: Generate the REST Client + +First, generate the RestClient.Net client from your OpenAPI spec: + +```bash +restclient-gen \ + --input api.yaml \ + --output Generated \ + --namespace MyApi.Client +``` + +### Step 2: Generate MCP Server + +Generate the MCP server code: + +```bash +restclient-mcp \ + --openapi api.yaml \ + --output McpServer \ + --namespace MyApi.Mcp \ + --server-name MyApiServer \ + --client-namespace MyApi.Client +``` + +### Step 3: Create MCP Server Project + +Create a new console project: + +```bash +dotnet new console -n MyApi.McpServer +cd MyApi.McpServer +dotnet add package Microsoft.Extensions.Hosting +dotnet add package RestClient.Net +``` + +Add the generated files and create the host: + +```csharp +// Program.cs +using Microsoft.Extensions.Hosting; +using MyApi.Mcp; + +var host = Host.CreateDefaultBuilder(args) + .ConfigureServices(services => + { + services.AddHttpClient("api", client => + { + client.BaseAddress = new Uri("https://api.example.com"); + }); + + services.AddMcpServer(); + }) + .Build(); + +await host.RunAsync(); +``` + +### Step 4: Configure Claude Code + +Add to your Claude Code configuration (`.claude/mcp.json`): + +```json +{ + "mcpServers": { + "myapi": { + "command": "dotnet", + "args": ["run", "--project", "path/to/MyApi.McpServer"] + } + } +} +``` + +## CLI Options + +| Option | Description | +|--------|-------------| +| `--openapi` | OpenAPI spec URL or file path (required) | +| `--output` | Output directory (default: `McpServer`) | +| `--namespace` | C# namespace for generated code (required) | +| `--server-name` | MCP server class name (required) | +| `--client-namespace` | Namespace of the generated REST client | +| `--tags` | Include only these operation tags (comma-separated) | +| `--exclude-tags` | Exclude these operation tags (comma-separated) | + +### Examples + +Include only specific operations: + +```bash +restclient-mcp \ + --openapi api.yaml \ + --output McpServer \ + --namespace MyApi.Mcp \ + --server-name MyApiServer \ + --client-namespace MyApi.Client \ + --tags "Users,Search" +``` + +Exclude admin operations: + +```bash +restclient-mcp \ + --openapi api.yaml \ + --output McpServer \ + --namespace MyApi.Mcp \ + --server-name MyApiServer \ + --client-namespace MyApi.Client \ + --exclude-tags "Admin,Internal" +``` + +## Generated Code Structure + +``` +McpServer/ +├── Tools/ +│ ├── GetUserTool.cs +│ ├── CreateUserTool.cs +│ └── ... +├── MyApiServer.cs +└── ServiceCollectionExtensions.cs +``` + +### Generated Tool Example + +```csharp +// McpServer/Tools/GetUserTool.cs +namespace MyApi.Mcp.Tools; + +public class GetUserTool(IHttpClientFactory httpClientFactory) : IMcpTool +{ + public string Name => "get_user"; + public string Description => "Retrieves a user by their ID"; + + public ToolParameters Parameters => new() + { + Properties = new Dictionary + { + ["userId"] = new() + { + Type = "string", + Description = "The unique identifier of the user", + Required = true + } + } + }; + + public async Task ExecuteAsync( + JsonElement arguments, + CancellationToken ct) + { + var userId = arguments.GetProperty("userId").GetString()!; + var client = httpClientFactory.CreateClient("api"); + + var result = await client.GetUserByIdAsync(userId, ct); + + return result switch + { + OkUser(var user) => ToolResult.Success(JsonSerializer.Serialize(user)), + ErrorUser(var error) => ToolResult.Error($"Failed: {error}"), + }; + } +} +``` + +## Authentication + +For authenticated APIs, configure the HttpClient: + +```csharp +services.AddHttpClient("api", client => +{ + client.BaseAddress = new Uri("https://api.example.com"); +}) +.AddHttpMessageHandler(); + +services.AddTransient(); +``` + +### Environment Variables + +Use environment variables for secrets: + +```csharp +public class AuthenticationHandler : DelegatingHandler +{ + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var token = Environment.GetEnvironmentVariable("API_TOKEN"); + request.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", token); + + return base.SendAsync(request, cancellationToken); + } +} +``` + +Configure in Claude Code: + +```json +{ + "mcpServers": { + "myapi": { + "command": "dotnet", + "args": ["run", "--project", "MyApi.McpServer"], + "env": { + "API_TOKEN": "your-api-token" + } + } + } +} +``` + +## Tool Naming + +The generator creates tool names from operation IDs: + +| Operation ID | Tool Name | +|--------------|-----------| +| `getUserById` | `get_user_by_id` | +| `createUser` | `create_user` | +| `deleteUserById` | `delete_user_by_id` | + +## Best Practices + +1. **Filter operations** - Only expose necessary tools to Claude +2. **Use descriptive operation summaries** - These become tool descriptions +3. **Secure sensitive operations** - Use authentication and authorization +4. **Handle errors gracefully** - Return meaningful error messages +5. **Test tools manually** - Verify behavior before Claude uses them + +## Debugging + +Run the MCP server locally to test: + +```bash +cd MyApi.McpServer +dotnet run +``` + +Use the MCP inspector to test tools: + +```bash +npx @anthropic-ai/mcp-inspector +``` + +## Next Steps + +- [Exhaustion Analyzer](/docs/exhaustion/) - Compile-time exhaustiveness +- [Advanced Usage](/docs/advanced-usage/) - Retry policies and middleware +- [API Reference](/api/) - Complete documentation +``` diff --git a/Website/src/docs/openapi.md b/Website/src/docs/openapi.md new file mode 100644 index 00000000..2f7b1c84 --- /dev/null +++ b/Website/src/docs/openapi.md @@ -0,0 +1,296 @@ +--- +layout: layouts/docs.njk +title: OpenAPI Generator +description: Generate type-safe C# REST clients from OpenAPI 3.x specifications. Automatic Result types, type aliases, and exhaustiveness checking. +keywords: OpenAPI generator, C# client generator, OpenAPI to C#, REST client generator, code generation +eleventyNavigation: + key: OpenAPI Generator + order: 6 +faq: + - question: What OpenAPI versions are supported? + answer: RestClient.Net.OpenApiGenerator supports OpenAPI 3.0 and 3.1 specifications in both YAML and JSON formats. + - question: Does the generator create type aliases automatically? + answer: Yes, the generator creates GlobalUsings.cs with all necessary type aliases for clean pattern matching. + - question: Can I customize the generated code? + answer: Yes, you can specify namespace, output directory, and filter operations by tags. The generated code follows RestClient.Net patterns. +--- + +# OpenAPI Client Generation + +Generate type-safe C# clients from OpenAPI 3.x specifications with automatic Result types and exhaustiveness checking. + +## Installation + +Install the generator CLI tool: + +```bash +dotnet tool install -g RestClient.Net.OpenApiGenerator.Cli +``` + +Or add to your project: + +```bash +dotnet add package RestClient.Net.OpenApiGenerator +``` + +## Quick Start + +Generate a client from an OpenAPI spec: + +```bash +restclient-gen \ + --input https://petstore3.swagger.io/api/v3/openapi.json \ + --output Generated \ + --namespace PetStore.Client +``` + +Or from a local file: + +```bash +restclient-gen \ + --input api.yaml \ + --output Generated \ + --namespace MyApi.Client +``` + +## CLI Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--input` | `-i` | OpenAPI spec URL or file path (required) | +| `--output` | `-o` | Output directory (default: `Generated`) | +| `--namespace` | `-n` | C# namespace for generated code (required) | +| `--tags` | `-t` | Filter operations by tags (comma-separated) | +| `--skip-validation` | | Skip OpenAPI spec validation | + +### Examples + +Generate only specific tags: + +```bash +restclient-gen \ + -i api.yaml \ + -o Generated \ + -n MyApi.Client \ + --tags "Users,Orders" +``` + +## Generated Files + +The generator creates: + +``` +Generated/ +├── Models/ +│ ├── User.cs +│ ├── Order.cs +│ └── ... +├── HttpClientExtensions.g.cs +├── Deserializers.g.cs +└── GlobalUsings.g.cs +``` + +### Models + +Data transfer objects from OpenAPI schemas: + +```csharp +// Generated/Models/User.cs +namespace MyApi.Client.Models; + +public sealed record User( + string Id, + string Name, + string Email, + DateTime CreatedAt +); +``` + +### HttpClient Extensions + +Extension methods for each operation: + +```csharp +// Generated/HttpClientExtensions.g.cs +namespace MyApi.Client; + +public static class HttpClientExtensions +{ + public static Task>> GetUserByIdAsync( + this HttpClient httpClient, + string userId, + CancellationToken ct = default) => + httpClient.GetAsync( + url: $"/users/{userId}".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.User, + deserializeError: Deserializers.ApiError, + cancellationToken: ct + ); + + public static Task>> CreateUserAsync( + this HttpClient httpClient, + CreateUserRequest request, + CancellationToken ct = default) => + httpClient.PostAsync( + url: "/users".ToAbsoluteUrl(), + body: request, + serializeRequest: Serializers.Json, + deserializeSuccess: Deserializers.User, + deserializeError: Deserializers.ApiError, + cancellationToken: ct + ); +} +``` + +### Type Aliases + +Automatically generated for clean pattern matching: + +```csharp +// Generated/GlobalUsings.g.cs +global using OkUser = Outcome.Result> + .Ok>; + +global using ErrorUser = Outcome.Result> + .Error>; + +global using ResponseErrorUser = Outcome.HttpError.ErrorResponseError; +global using ExceptionErrorUser = Outcome.HttpError.ExceptionError; +``` + +## Using Generated Code + +```csharp +using MyApi.Client; +using MyApi.Client.Models; + +// Create HttpClient (use IHttpClientFactory in production) +using var httpClient = new HttpClient +{ + BaseAddress = new Uri("https://api.example.com") +}; + +// Use generated extension methods +var result = await httpClient.GetUserByIdAsync("123"); + +// Pattern match with generated type aliases +var message = result switch +{ + OkUser(var user) => $"Found: {user.Name} ({user.Email})", + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"API Error {status}: {err.Message}", + ErrorUser(ExceptionErrorUser(var ex)) => $"Exception: {ex.Message}", +}; + +Console.WriteLine(message); +``` + +## OpenAPI Spec Requirements + +### Supported Features + +- Path parameters +- Query parameters +- Request bodies (JSON) +- Response bodies (JSON) +- Schema references (`$ref`) +- Enums +- Arrays and nested objects +- Required/optional properties + +### Example OpenAPI Spec + +```yaml +openapi: 3.0.3 +info: + title: My API + version: 1.0.0 + +paths: + /users/{id}: + get: + operationId: getUserById + tags: [Users] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: User found + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: User not found + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + +components: + schemas: + User: + type: object + required: [id, name, email] + properties: + id: + type: string + name: + type: string + email: + type: string + format: email + createdAt: + type: string + format: date-time + + ApiError: + type: object + required: [message] + properties: + message: + type: string + code: + type: string +``` + +## Build Integration + +### MSBuild Target + +Add to your `.csproj` to regenerate on build: + +```xml + + + +``` + +### Pre-build Event + +Or use a pre-build event: + +```xml + + + restclient-gen -i $(ProjectDir)api.yaml -o $(ProjectDir)Generated -n MyApi.Client + + +``` + +## Best Practices + +1. **Version your OpenAPI specs** alongside your code +2. **Regenerate clients** when specs change +3. **Use tags** to organize and filter operations +4. **Review generated code** before committing +5. **Add generated files to .gitignore** or commit them based on your workflow + +## Next Steps + +- [MCP Server Generation](/docs/mcp/) - Generate Claude Code tools +- [Error Handling](/docs/error-handling/) - Work with Result types +- [API Reference](/api/) - Complete documentation diff --git a/Website/src/examples/index.njk b/Website/src/examples/index.njk new file mode 100644 index 00000000..7c8d25dc --- /dev/null +++ b/Website/src/examples/index.njk @@ -0,0 +1,280 @@ +--- +layout: layouts/base.njk +title: Code Examples +permalink: /examples/ +--- +
+

Code Examples

+

Complete, working examples for common scenarios

+ +
+ +
+

Basic GET Request

+

Fetch data from a REST API with type-safe error handling.

+ {% highlight "csharp" %}using System.Net.Http.Json; +using RestClient.Net; +using Urls; + +// Define models +record Post(int UserId, int Id, string Title, string Body); +record ApiError(string Message); + +// Type aliases for clean pattern matching +using OkPost = Outcome.Result> + .Ok>; +using ErrorPost = Outcome.Result> + .Error>; +using ResponseErrorPost = Outcome.HttpError.ErrorResponseError; +using ExceptionErrorPost = Outcome.HttpError.ExceptionError; + +// Make the request +using var httpClient = new HttpClient(); +var result = await httpClient.GetAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) ?? throw new Exception("Null"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) ?? new ApiError("Unknown") +); + +// Handle all cases +var message = result switch +{ + OkPost(var post) => $"Title: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"Error {status}: {err.Message}", + ErrorPost(ExceptionErrorPost(var ex)) => $"Exception: {ex.Message}", +}; + +Console.WriteLine(message);{% endhighlight %} +
+ +
+

POST Request with Body

+

Create a new resource with a JSON request body.

+ {% highlight "csharp" %}record CreatePostRequest(string Title, string Body, int UserId); + +var newPost = new CreatePostRequest("My Title", "My content", 1); + +var result = await httpClient.PostAsync( + url: "https://jsonplaceholder.typicode.com/posts".ToAbsoluteUrl(), + body: newPost, + serializeRequest: body => JsonContent.Create(body), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) ?? throw new Exception("Null"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) ?? new ApiError("Unknown") +); + +var message = result switch +{ + OkPost(var post) => $"Created post with ID: {post.Id}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"Failed: {status}", + ErrorPost(ExceptionErrorPost(var ex)) => $"Exception: {ex.Message}", +};{% endhighlight %} +
+ +
+

Using IHttpClientFactory

+

Proper HttpClient usage in ASP.NET Core applications.

+ {% highlight "csharp" %}// Program.cs - Register the client +builder.Services.AddHttpClient("api", client => +{ + client.BaseAddress = new Uri("https://api.example.com"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// UserService.cs - Use the client +public class UserService(IHttpClientFactory httpClientFactory) +{ + public async Task>> GetUserAsync( + string userId, + CancellationToken ct = default) + { + var client = httpClientFactory.CreateClient("api"); + + return await client.GetAsync( + url: $"/users/{userId}".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error, + cancellationToken: ct + ); + } +}{% endhighlight %} +
+ +
+

Retry Policy with Polly

+

Add automatic retries for transient failures.

+ {% highlight "csharp" %}// Program.cs +builder.Services.AddHttpClient("api") + .AddStandardResilienceHandler(options => + { + options.Retry.MaxRetryAttempts = 3; + options.Retry.Delay = TimeSpan.FromMilliseconds(500); + options.Retry.UseJitter = true; + options.Retry.ShouldHandle = args => ValueTask.FromResult( + args.Outcome.Exception is not null || + args.Outcome.Result?.StatusCode >= HttpStatusCode.InternalServerError + ); + });{% endhighlight %} +
+ +
+

Authentication Handler

+

Automatically add authentication tokens to requests.

+ {% highlight "csharp" %}public class AuthenticationHandler(ITokenService tokenService) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var token = await tokenService.GetAccessTokenAsync(cancellationToken); + + request.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", token); + + var response = await base.SendAsync(request, cancellationToken); + + // Refresh token if expired + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + token = await tokenService.RefreshTokenAsync(cancellationToken); + request.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", token); + response = await base.SendAsync(request, cancellationToken); + } + + return response; + } +} + +// Program.cs +builder.Services.AddTransient(); +builder.Services.AddHttpClient("api") + .AddHttpMessageHandler();{% endhighlight %} +
+ +
+

Status Code Specific Handling

+

Handle different HTTP status codes differently.

+ {% highlight "csharp" %}var result = await httpClient.GetUserAsync(userId); + +var message = result switch +{ + OkUser(var user) => $"Found: {user.Name}", + + // Not Found - user doesn't exist + ErrorUser(ResponseErrorUser(_, HttpStatusCode.NotFound, _)) => + "User not found. Please check the ID.", + + // Unauthorized - need to log in + ErrorUser(ResponseErrorUser(_, HttpStatusCode.Unauthorized, _)) => + "Please log in to view this user.", + + // Forbidden - not allowed + ErrorUser(ResponseErrorUser(_, HttpStatusCode.Forbidden, _)) => + "You don't have permission to view this user.", + + // Rate limited + ErrorUser(ResponseErrorUser(_, HttpStatusCode.TooManyRequests, var response)) => + { + var retryAfter = response.Headers.RetryAfter?.Delta; + return $"Too many requests. Try again in {retryAfter?.TotalSeconds ?? 60} seconds."; + }, + + // Server error + ErrorUser(ResponseErrorUser(var err, var status, _)) when (int)status >= 500 => + "The server is experiencing issues. Please try again later.", + + // Other API errors + ErrorUser(ResponseErrorUser(var err, var status, _)) => + $"API Error {(int)status}: {err.Message}", + + // Network/timeout errors + ErrorUser(ExceptionErrorUser(TaskCanceledException ex)) + when ex.CancellationToken.IsCancellationRequested => + "Request was cancelled.", + + ErrorUser(ExceptionErrorUser(TaskCanceledException)) => + "Request timed out. Please try again.", + + ErrorUser(ExceptionErrorUser(HttpRequestException)) => + "Network error. Please check your connection.", + + ErrorUser(ExceptionErrorUser(var ex)) => + $"Unexpected error: {ex.Message}", +};{% endhighlight %} +
+ +
+

Chaining Multiple Requests

+

Chain dependent API calls with proper error propagation.

+ {% highlight "csharp" %}// Get user, then get their orders, then get order details +public async Task>> GetUserOrderDetailsAsync( + string userId, + CancellationToken ct) +{ + // First, get the user + var userResult = await httpClient.GetUserAsync(userId, ct); + + return await userResult switch + { + OkUser(var user) => await GetOrdersForUserAsync(user, ct), + ErrorUser(var error) => new Result> + .Error(error), + }; +} + +private async Task>> GetOrdersForUserAsync( + User user, + CancellationToken ct) +{ + var ordersResult = await httpClient.GetOrdersAsync(user.Id, ct); + + return ordersResult switch + { + OkOrders(var orders) => new Result> + .Ok(new OrderDetails(user, orders)), + ErrorOrders(var error) => new Result> + .Error(error), + }; +}{% endhighlight %} +
+ +
+

Parallel Requests

+

Make multiple independent requests in parallel.

+ {% highlight "csharp" %}public async Task GetDashboardAsync(string userId, CancellationToken ct) +{ + // Start all requests in parallel + var userTask = httpClient.GetUserAsync(userId, ct); + var ordersTask = httpClient.GetOrdersAsync(userId, ct); + var notificationsTask = httpClient.GetNotificationsAsync(userId, ct); + + // Wait for all to complete + await Task.WhenAll(userTask, ordersTask, notificationsTask); + + var userResult = await userTask; + var ordersResult = await ordersTask; + var notificationsResult = await notificationsTask; + + // Combine results + return (userResult, ordersResult, notificationsResult) switch + { + (OkUser(var user), OkOrders(var orders), OkNotifications(var notifications)) => + new Dashboard(user, orders, notifications), + + (ErrorUser(var e), _, _) => + throw new Exception($"Failed to load user: {e}"), + (_, ErrorOrders(var e), _) => + throw new Exception($"Failed to load orders: {e}"), + (_, _, ErrorNotifications(var e)) => + throw new Exception($"Failed to load notifications: {e}"), + }; +}{% endhighlight %} +
+ +
+
diff --git a/Website/src/index.njk b/Website/src/index.njk new file mode 100644 index 00000000..4b3c0e3a --- /dev/null +++ b/Website/src/index.njk @@ -0,0 +1,117 @@ +--- +layout: layouts/base.njk +title: RestClient.Net - Type-Safe REST Client for C# +lang: en +permalink: / +--- + +
+
+ +

RestClient.Net

+

The safest way to make REST calls in C#. Built with functional programming, compile-time exhaustiveness, and modern .NET patterns.

+ +
+
+ +
+
+
+

Result Types

+

Returns Result<TSuccess, HttpError<TError>> with closed hierarchy types for compile-time safety. No more exception handling guesswork.

+
+ +
+

Zero Exceptions

+

No exception throwing for predictable error handling. Every possible outcome is represented in the type system.

+
+ +
+

Exhaustiveness Checking

+

Uses the Exhaustion analyzer for compile-time completeness guarantees. If you don't handle all cases, it won't compile.

+
+ +
+

HttpClient Extensions

+

Works with IHttpClientFactory.CreateClient() for proper pooled connections and DNS behavior handling.

+
+ +
+

OpenAPI Generator

+

Generate type-safe C# clients from OpenAPI 3.x specs. Automatic model generation and result type aliases.

+
+ +
+

MCP Server Generator

+

Generate Model Context Protocol servers for Claude Code from OpenAPI specs. AI-ready API integration.

+
+
+
+ +
+

Quick Install

+ {% highlight "bash" %}dotnet add package RestClient.Net{% endhighlight %} +
+ +
+

Basic Usage

+ {% highlight "csharp" %}using RestClient.Net; + +// Make a GET request +var result = await httpClient + .GetAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializePost, + deserializeError: DeserializeError + ); + +// Pattern match on the result - MUST handle all cases +var output = result switch +{ + OkPost(var post) => $"Success: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"Error {status}", + ErrorPost(ExceptionErrorPost(var ex)) => $"Exception: {ex.Message}", +};{% endhighlight %} +
+ +
+
+

Why Discriminated Unions?

+

+ C# doesn't officially support discriminated unions yet, but RestClient.Net brings this powerful pattern to your code today. With the Exhaustion analyzer, missing a case means your code won't compile. +

+ +
+
+

Without Exhaustion

+ {% highlight "csharp" %}// DANGEROUS - compiles but may throw +var output = result switch +{ + OkPost(var post) => "Success", + ErrorPost(ResponseErrorPost(...)) => "Error", + // Missing ExceptionErrorPost! + // Runtime crash waiting to happen +};{% endhighlight %} +
+
+

With Exhaustion

+ {% highlight "csharp" %}// SAFE - compiler error! +// error EXHAUSTION001: Switch not exhaustive +// Missing: ExceptionErrorPost +// Build fails until you handle all cases{% endhighlight %} +
+
+
+
+ +
+

Ready to Get Started?

+ +
diff --git a/Website/src/zh/api/configuration.md b/Website/src/zh/api/configuration.md new file mode 100644 index 00000000..9a4760b4 --- /dev/null +++ b/Website/src/zh/api/configuration.md @@ -0,0 +1,138 @@ +--- +layout: layouts/docs.njk +title: 配置 +lang: zh +permalink: /zh/api/configuration/ +eleventyNavigation: + key: 配置 + parent: API 参考 + order: 4 +--- + +# 配置 + +RestClient.Net 与标准的 \`HttpClient\` 和 \`IHttpClientFactory\` 模式配合使用。无需特殊配置。 + +## 使用 IHttpClientFactory(推荐) + +```csharp +// Program.cs +builder.Services.AddHttpClient("MyApi", client => +{ + client.BaseAddress = new Uri("https://api.example.com/"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); +}); + +// 在您的服务中 +public class UserService +{ + private readonly HttpClient _httpClient; + + public UserService(IHttpClientFactory factory) + { + _httpClient = factory.CreateClient("MyApi"); + } + + public async Task>> GetUser(int id) => + await _httpClient.GetAsync( + url: \$"users/{id}".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error + ); +} +``` + +## 身份验证 + +### Bearer Token + +```csharp +builder.Services.AddHttpClient("MyApi", client => +{ + client.BaseAddress = new Uri("https://api.example.com/"); +}) +.AddHttpMessageHandler(); + +public class AuthHeaderHandler : DelegatingHandler +{ + private readonly ITokenProvider _tokens; + + public AuthHeaderHandler(ITokenProvider tokens) => _tokens = tokens; + + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken ct) + { + var token = await _tokens.GetTokenAsync(ct); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + return await base.SendAsync(request, ct); + } +} +``` + +### API Key + +```csharp +builder.Services.AddHttpClient("MyApi", client => +{ + client.DefaultRequestHeaders.Add("X-API-Key", configuration["ApiKey"]); +}); +``` + +## 使用 Polly 的重试策略 + +```csharp +builder.Services.AddHttpClient("MyApi") + .AddTransientHttpErrorPolicy(p => + p.WaitAndRetryAsync(3, retryAttempt => + TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)))); +``` + +## 超时设置 + +```csharp +builder.Services.AddHttpClient("MyApi", client => +{ + client.Timeout = TimeSpan.FromSeconds(30); +}); +``` + +## 基础地址与相对 URL + +使用 \`BaseAddress\` 时,您可以使用相对 URL: + +```csharp +// 当 BaseAddress = "https://api.example.com/" +var result = await httpClient.GetAsync( + url: "users/123".ToAbsoluteUrl(), // 解析为完整 URL + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error +); +``` + +## 每个请求的自定义头 + +可以通过 \`HttpRequestMessage\` 设置头信息以获得更多控制: + +```csharp +var request = new HttpRequestMessage(HttpMethod.Get, "users/123"); +request.Headers.Add("X-Request-ID", Guid.NewGuid().ToString()); + +var response = await httpClient.SendAsync(request); +``` + +## 依赖注入 + +注册您的类型化客户端: + +```csharp +// Program.cs +builder.Services.AddScoped(); + +// UserService.cs +public interface IUserService +{ + Task>> GetUser(int id); + Task>> CreateUser(CreateUserRequest request); +} +``` diff --git a/Website/src/zh/api/http-methods.md b/Website/src/zh/api/http-methods.md new file mode 100644 index 00000000..2a6756c0 --- /dev/null +++ b/Website/src/zh/api/http-methods.md @@ -0,0 +1,129 @@ +--- +layout: layouts/docs.njk +title: HTTP 方法 +lang: zh +permalink: /zh/api/http-methods/ +eleventyNavigation: + key: HTTP 方法 + parent: API 参考 + order: 1 +--- + +# HTTP 方法 + +RestClient.Net 为 `HttpClient` 的所有标准 HTTP 方法提供类型安全的扩展方法。 + +## GET 请求 + +从服务器获取数据。 + +```csharp +var result = await httpClient.GetAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializePost, + deserializeError: DeserializeError +); +``` + +### 参数 + +| 参数 | 类型 | 描述 | +|------|------|------| +| `url` | `AbsoluteUrl` | 端点 URL(使用 `.ToAbsoluteUrl()` 扩展) | +| `deserializeSuccess` | `Func>` | 成功响应的反序列化器 | +| `deserializeError` | `Func>` | 错误响应的反序列化器 | +| `cancellationToken` | `CancellationToken` | 可选的取消令牌 | + +## POST 请求 + +发送数据以创建新资源。 + +```csharp +var result = await httpClient.PostAsync( + url: "https://api.example.com/posts".ToAbsoluteUrl(), + body: new CreatePostRequest("Title", "Body"), + serializeBody: SerializeRequest, + deserializeSuccess: DeserializePost, + deserializeError: DeserializeError +); +``` + +### 参数 + +| 参数 | 类型 | 描述 | +|------|------|------| +| `url` | `AbsoluteUrl` | 端点 URL | +| `body` | `TBody` | 请求体对象 | +| `serializeBody` | `Func` | 请求体序列化器 | +| `deserializeSuccess` | `Func>` | 成功响应的反序列化器 | +| `deserializeError` | `Func>` | 错误响应的反序列化器 | + +## PUT 请求 + +完全替换现有资源。 + +```csharp +var result = await httpClient.PutAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + body: new UpdatePostRequest(1, "New Title", "New Body"), + serializeBody: SerializeRequest, + deserializeSuccess: DeserializePost, + deserializeError: DeserializeError +); +``` + +## PATCH 请求 + +部分更新现有资源。 + +```csharp +var result = await httpClient.PatchAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + body: new PatchPostRequest { Title = "Updated Title" }, + serializeBody: SerializeRequest, + deserializeSuccess: DeserializePost, + deserializeError: DeserializeError +); +``` + +## DELETE 请求 + +删除资源。 + +```csharp +var result = await httpClient.DeleteAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializeDeleteResponse, + deserializeError: DeserializeError +); +``` + +## 常见模式 + +### 添加请求头 + +```csharp +httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token); + +var result = await httpClient.GetAsync(...); +``` + +### 使用 IHttpClientFactory + +```csharp +// 在 Startup.cs / Program.cs 中 +services.AddHttpClient("api", client => +{ + client.BaseAddress = new Uri("https://api.example.com"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); +}); + +// 在你的服务中 +var httpClient = httpClientFactory.CreateClient("api"); +var result = await httpClient.GetAsync(...); +``` + +## 返回类型 + +所有方法都返回 `Result>`。有关处理响应的详细信息,请参阅 [Result 类型](/zh/api/result-types/)。 diff --git a/Website/src/zh/api/httpclient-extensions.md b/Website/src/zh/api/httpclient-extensions.md new file mode 100644 index 00000000..7afe85a2 --- /dev/null +++ b/Website/src/zh/api/httpclient-extensions.md @@ -0,0 +1,150 @@ +--- +layout: layouts/docs.njk +title: HttpClient 扩展方法 +lang: zh +eleventyNavigation: + key: HttpClient 扩展方法 + parent: API 参考 + order: 1 +permalink: /zh/api/httpclient-extensions/ +--- + +# HttpClient 扩展方法 + +为 `HttpClient` 提供的扩展方法,返回 `Result>` 而不是抛出异常。 + +## GetAsync + +发起类型安全的 GET 请求。 + +```csharp +public static async Task>> GetAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Func> deserializeSuccess, + Func> deserializeError, + CancellationToken cancellationToken = default +) +``` + +### 参数 + +| 参数 | 类型 | 描述 | +|------|------|------| +| `url` | `AbsoluteUrl` | 请求的 URL(使用 `.ToAbsoluteUrl()` 扩展方法) | +| `deserializeSuccess` | `Func>` | 反序列化成功响应的函数 | +| `deserializeError` | `Func>` | 反序列化错误响应的函数 | +| `cancellationToken` | `CancellationToken` | 可选的取消令牌 | + +### 返回值 + +`Task>>` - 一个可辨识联合,可能是: +- `Ok` - 成功,包含反序列化的数据 +- `Error>` - 错误,包含 `ResponseError` 或 `ExceptionError` + +### 示例 + +```csharp +var result = await httpClient.GetAsync( + url: "https://api.example.com/users/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializeUser, + deserializeError: DeserializeApiError +); + +var output = result switch +{ + OkUser(var user) => $"找到: {user.Name}", + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"API 错误 {status}: {err.Message}", + ErrorUser(ExceptionErrorUser(var ex)) => $"异常: {ex.Message}", +}; +``` + +--- + +## PostAsync + +发起带请求体的类型安全 POST 请求。 + +```csharp +public static async Task>> PostAsync( + this HttpClient httpClient, + AbsoluteUrl url, + TRequest body, + Func serializeRequest, + Func> deserializeSuccess, + Func> deserializeError, + CancellationToken cancellationToken = default +) +``` + +### 参数 + +| 参数 | 类型 | 描述 | +|------|------|------| +| `url` | `AbsoluteUrl` | 请求的 URL | +| `body` | `TRequest` | 请求体对象 | +| `serializeRequest` | `Func` | 序列化请求体的函数 | +| `deserializeSuccess` | `Func>` | 反序列化成功响应的函数 | +| `deserializeError` | `Func>` | 反序列化错误响应的函数 | +| `cancellationToken` | `CancellationToken` | 可选的取消令牌 | + +### 示例 + +```csharp +var newUser = new CreateUserRequest("张三", "zhangsan@example.com"); + +var result = await httpClient.PostAsync( + url: "https://api.example.com/users".ToAbsoluteUrl(), + body: newUser, + serializeRequest: SerializeJson, + deserializeSuccess: DeserializeUser, + deserializeError: DeserializeApiError +); +``` + +--- + +## PutAsync + +发起类型安全的 PUT 请求。签名与 `PostAsync` 相同,用于更新现有资源。 + +--- + +## DeleteAsync + +发起类型安全的 DELETE 请求。签名与 `GetAsync` 相同,用于删除资源。 + +--- + +## PatchAsync + +发起类型安全的 PATCH 请求。签名与 `PostAsync` 相同,用于部分更新。 + +--- + +## 与 IHttpClientFactory 配合使用 + +RestClient.Net 与 `IHttpClientFactory` 无缝集成,实现正确的连接池管理: + +```csharp +// 在 Program.cs 中 +builder.Services.AddHttpClient("api", client => +{ + client.BaseAddress = new Uri("https://api.example.com"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); +}); + +// 在您的服务中 +public class UserService(IHttpClientFactory factory) +{ + public async Task>> GetUserAsync(int id) + { + var client = factory.CreateClient("api"); + return await client.GetAsync( + url: $"/users/{id}".ToAbsoluteUrl(), + deserializeSuccess: DeserializeUser, + deserializeError: DeserializeApiError + ); + } +} +``` diff --git a/Website/src/zh/api/index.njk b/Website/src/zh/api/index.njk new file mode 100644 index 00000000..8090b7de --- /dev/null +++ b/Website/src/zh/api/index.njk @@ -0,0 +1,82 @@ +--- +layout: layouts/base.njk +title: API 参考 +lang: zh +permalink: /zh/api/ +--- +
+

API 参考

+

RestClient.Net 完整 API 文档

+ +

核心 API

+ + +

代码生成器

+ + +

指南

+ + +

NuGet 包

+ + + + + + + + + + + + + + + + + + + + + + + + + +
包名描述
RestClient.Net包含 HttpClient 扩展的核心库
RestClient.Net.OpenApiGeneratorOpenAPI 3.x 客户端生成器
RestClient.Net.McpGeneratorMCP 服务器生成器
Exhaustion用于 switch 穷尽性检查的 Roslyn 分析器
+
diff --git a/Website/src/zh/api/mcp-generator.md b/Website/src/zh/api/mcp-generator.md new file mode 100644 index 00000000..2fb0df9e --- /dev/null +++ b/Website/src/zh/api/mcp-generator.md @@ -0,0 +1,148 @@ +--- +layout: layouts/docs.njk +title: MCP 生成器 +lang: zh +permalink: /zh/api/mcp-generator/ +eleventyNavigation: + key: MCP 生成器 + parent: API 参考 + order: 6 +--- + +# MCP 生成器 + +从 OpenAPI 规范生成模型上下文协议(MCP)服务器,用于与 Claude Code 和其他工具的 AI 集成。 + +## 安装 + +```bash +dotnet add package RestClient.Net.McpGenerator +``` + +## 前置条件 + +首先,生成 REST 客户端: + +```bash +dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \ + -u api.yaml -o Generated -n YourApi.Generated +``` + +## 命令行使用 + +```bash +dotnet run --project RestClient.Net.McpGenerator.Cli -- \ + --openapi-url api.yaml \ + --output-file Generated/McpTools.g.cs \ + --namespace YourApi.Mcp \ + --server-name YourApi \ + --ext-namespace YourApi.Generated \ + --tags "Search,Resources" +``` + +### 命令行选项 + +| 选项 | 描述 | +|------|------| +| \`--openapi-url\` | OpenAPI 规范的路径 | +| \`--output-file\` | 生成的 MCP 工具输出文件 | +| \`--namespace\` | MCP 服务器的 C# 命名空间 | +| \`--server-name\` | MCP 服务器的名称 | +| \`--ext-namespace\` | 生成的 REST 客户端的命名空间 | +| \`--tags\` | 要包含的 OpenAPI 标签(逗号分隔) | + +## 生成的代码 + +生成器创建包装 REST 客户端的 MCP 工具定义: + +```csharp +[McpServerToolType] +public static partial class McpTools +{ + [McpServerTool(Name = "get_user")] + [Description("通过 ID 获取用户")] + public static async Task GetUser( + [Description("用户 ID")] string id, + HttpClient httpClient, + CancellationToken ct) + { + var result = await httpClient.GetUserById(id, ct); + return result switch + { + OkUser(var user) => JsonSerializer.Serialize(user), + ErrorUser(var error) => \$"错误: {error}" + }; + } +} +``` + +## Claude Code 集成 + +添加到您的 Claude Code 配置(\`.claude/settings.json\` 或 \`claude_desktop_config.json\`): + +```json +{ + "mcpServers": { + "yourapi": { + "command": "dotnet", + "args": ["run", "--project", "YourApi.McpServer"] + } + } +} +``` + +## MCP 服务器项目 + +创建控制台应用程序来托管 MCP 服务器: + +```csharp +// Program.cs +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using YourApi.Mcp; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddHttpClient(); +builder.Services.AddMcpServer() + .WithStdioServerTransport() + .WithToolsFromAssembly(typeof(McpTools).Assembly); + +var host = builder.Build(); +await host.RunAsync(); +``` + +## 工具命名 + +OpenAPI 操作会转换为 MCP 工具名称: + +| OpenAPI | MCP 工具 | +|---------|----------| +| \`GET /users/{id}\` | \`get_user\` | +| \`POST /users\` | \`create_user\` | +| \`PUT /users/{id}\` | \`update_user\` | +| \`DELETE /users/{id}\` | \`delete_user\` | + +## 按标签筛选 + +使用 \`--tags\` 为特定 API 部分生成工具: + +```bash +# 仅生成搜索端点的工具 +--tags "Search" + +# 多个标签 +--tags "Search,Users,Products" +``` + +## 错误处理 + +生成的工具在成功时返回 JSON,失败时返回错误字符串,便于 AI 理解: + +```csharp +// 成功:JSON 响应 +{"id": "123", "name": "Alice", "email": "alice@example.com"} + +// 错误:人类可读的消息 +"错误: 用户未找到 (HTTP 404)" +``` diff --git a/Website/src/zh/api/openapi-generator.md b/Website/src/zh/api/openapi-generator.md new file mode 100644 index 00000000..1ee4aa50 --- /dev/null +++ b/Website/src/zh/api/openapi-generator.md @@ -0,0 +1,113 @@ +--- +layout: layouts/docs.njk +title: OpenAPI 生成器 +lang: zh +permalink: /zh/api/openapi-generator/ +eleventyNavigation: + key: OpenAPI 生成器 + parent: API 参考 + order: 5 +--- + +# OpenAPI 生成器 + +从 OpenAPI 3.x 规范生成类型安全的 C# 客户端。 + +## 安装 + +```bash +dotnet add package RestClient.Net.OpenApiGenerator +``` + +## 命令行使用 + +```bash +dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \ + -u api.yaml \ + -o Generated \ + -n YourApi.Generated +``` + +### 命令行选项 + +| 选项 | 简写 | 描述 | +|------|------|------| +| \`--openapi-url\` | \`-u\` | OpenAPI 规范的路径(YAML 或 JSON) | +| \`--output\` | \`-o\` | 生成文件的输出目录 | +| \`--namespace\` | \`-n\` | 生成代码的 C# 命名空间 | +| \`--client-name\` | \`-c\` | 生成的客户端类名称前缀 | + +## 生成的代码 + +生成器会创建: + +1. **模型类** - 所有 schema 对应的类 +2. **HttpClient 扩展方法** - 每个端点对应的方法 +3. **Result 类型别名** - 简洁的模式匹配 + +### 输出示例 + +对于包含 \`/users/{id}\` 端点的 OpenAPI 规范: + +```csharp +// 生成的扩展方法 +public static async Task GetUserById( + this HttpClient httpClient, + string id, + CancellationToken ct = default) +{ + return await httpClient.GetAsync( + url: \$"https://api.example.com/users/{id}".ToAbsoluteUrl(), + deserializeSuccess: async (r, c) => await r.Content.ReadFromJsonAsync(c), + deserializeError: async (r, c) => await r.Content.ReadFromJsonAsync(c), + ct + ); +} + +// 生成的类型别名 +global using ResultUser = Outcome.Result>; +global using OkUser = ResultUser.Ok>; +global using ErrorUser = ResultUser.Error>; +``` + +### 使用方法 + +```csharp +using YourApi.Generated; + +var httpClient = factory.CreateClient(); + +// 类型安全的 API 调用 +var result = await httpClient.GetUserById("123"); + +// 模式匹配处理结果 +var output = result switch +{ + OkUser(var user) => \$"找到: {user.Name}", + ErrorUser(ResponseErrorUser(var err, var status, _)) => \$"错误 {status}", + ErrorUser(ExceptionErrorUser(var ex)) => \$"异常: {ex.Message}", +}; +``` + +## 支持的 OpenAPI 特性 + +- **HTTP 方法:** GET、POST、PUT、DELETE、PATCH +- **参数:** path、query、header +- **请求体:** JSON、表单数据 +- **响应:** 所有状态码、多种内容类型 +- **Schema:** objects、arrays、enums、oneOf、allOf、anyOf +- **引用:** \$ref 本地和远程 schema + +## 配置 + +### 自定义基础 URL + +```csharp +// 生成的代码使用 OpenAPI 中的服务器 URL +// 运行时通过 HttpClient.BaseAddress 覆盖 +httpClient.BaseAddress = new Uri("https://staging.api.example.com/"); +``` + +### 自定义序列化 + +生成的代码默认使用 \`System.Text.Json\`。如需自定义序列化,可修改生成的反序列化器。 diff --git a/Website/src/zh/api/result-types.md b/Website/src/zh/api/result-types.md new file mode 100644 index 00000000..c8f984cb --- /dev/null +++ b/Website/src/zh/api/result-types.md @@ -0,0 +1,220 @@ +--- +layout: layouts/docs.njk +title: Result 类型 +lang: zh +permalink: /zh/api/result-types/ +eleventyNavigation: + key: Result 类型 + parent: API 参考 + order: 2 +--- + +# Result 类型 + +RestClient.Net 使用可辨识联合类型来表示 HTTP 请求的所有可能结果。这使得配合 Exhaustion 分析器可以实现编译时穷尽性检查。 + +## Result + +表示成功或错误的核心结果类型。 + +```csharp +public abstract record Result +{ + public sealed record Ok(TSuccess Value) : Result; + public sealed record Error(TError Value) : Result; +} +``` + +### 模式匹配 + +```csharp +Result> result = await GetUserAsync(); + +var output = result switch +{ + Result>.Ok(var user) => user.Name, + Result>.Error(var error) => "错误", +}; +``` + +--- + +## HttpError + +表示 HTTP 级别的错误,区分响应错误和异常。 + +```csharp +public abstract record HttpError +{ + // 服务器返回错误响应(4xx、5xx) + public sealed record ResponseError( + TError Error, + HttpStatusCode StatusCode, + HttpResponseMessage Response + ) : HttpError; + + // 发生异常(网络错误、超时等) + public sealed record ExceptionError( + Exception Exception + ) : HttpError; +} +``` + +### ResponseError + +当服务器返回非成功状态码时发生: + +| 属性 | 类型 | 描述 | +|------|------|------| +| `Error` | `TError` | 反序列化的错误响应体 | +| `StatusCode` | `HttpStatusCode` | HTTP 状态码(如 404、500) | +| `Response` | `HttpResponseMessage` | 完整的 HTTP 响应,用于获取头信息等 | + +### ExceptionError + +在请求过程中抛出异常时发生: + +| 属性 | 类型 | 描述 | +|------|------|------| +| `Exception` | `Exception` | 捕获的异常 | + +常见异常: +- `HttpRequestException` - 网络连接问题 +- `TaskCanceledException` - 请求超时或取消 +- `JsonException` - 反序列化失败 + +--- + +## 类型别名 + +为了更简洁的模式匹配,在 `GlobalUsings.cs` 中定义类型别名: + +```csharp +// 对于带有 ApiError 的 User 类型 +global using OkUser = Outcome.Result> + .Ok>; + +global using ErrorUser = Outcome.Result> + .Error>; + +global using ResponseErrorUser = Outcome.HttpError.ErrorResponseError; + +global using ExceptionErrorUser = Outcome.HttpError.ExceptionError; +``` + +### 使用类型别名 + +```csharp +var output = result switch +{ + OkUser(var user) => $"找到: {user.Name}", + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"错误 {status}", + ErrorUser(ExceptionErrorUser(var ex)) => $"异常: {ex.Message}", +}; +``` + +--- + +## 穷尽性检查 + +配合 [Exhaustion 分析器](/zh/docs/exhaustion/),遗漏情况会导致编译错误: + +```csharp +// 编译错误:EXHAUSTION001 +// 遗漏情况:ExceptionErrorUser +var output = result switch +{ + OkUser(var user) => "成功", + ErrorUser(ResponseErrorUser(...)) => "响应错误", + // ExceptionErrorUser 未处理! +}; +``` + +```csharp +// 编译通过:所有情况已处理 +var output = result switch +{ + OkUser(var user) => "成功", + ErrorUser(ResponseErrorUser(...)) => "响应错误", + ErrorUser(ExceptionErrorUser(...)) => "异常", +}; +``` + +--- + +## 映射结果 + +### Map Success + +转换成功值同时保留错误: + +```csharp +public static Result Map( + this Result result, + Func mapper +) => result switch +{ + Result.Ok(var value) => + new Result.Ok(mapper(value)), + Result.Error(var error) => + new Result.Error(error), +}; + +// 用法 +var nameResult = userResult.Map(user => user.Name); +``` + +### FlatMap / Bind + +链接返回 Result 的操作: + +```csharp +public static async Task> FlatMapAsync( + this Result result, + Func>> mapper +) => result switch +{ + Result.Ok(var value) => await mapper(value), + Result.Error(var error) => + new Result.Error(error), +}; + +// 用法:获取用户,然后获取其订单 +var ordersResult = await userResult.FlatMapAsync( + user => GetOrdersAsync(user.Id) +); +``` + +--- + +## 常见模式 + +### 错误时使用默认值 + +```csharp +var user = result switch +{ + OkUser(var u) => u, + _ => User.Empty, +}; +``` + +### 错误时抛出异常(逃生出口) + +```csharp +var user = result switch +{ + OkUser(var u) => u, + ErrorUser(var e) => throw new InvalidOperationException($"失败: {e}"), +}; +``` + +### 转换为可空类型 + +```csharp +User? user = result switch +{ + OkUser(var u) => u, + _ => null, +}; +``` diff --git a/Website/src/zh/api/serialization.md b/Website/src/zh/api/serialization.md new file mode 100644 index 00000000..8b797b48 --- /dev/null +++ b/Website/src/zh/api/serialization.md @@ -0,0 +1,149 @@ +--- +layout: layouts/docs.njk +title: 序列化 +lang: zh +permalink: /zh/api/serialization/ +eleventyNavigation: + key: 序列化 + parent: API 参考 + order: 3 +--- + +# 序列化 + +RestClient.Net 使用基于函数的序列化来实现最大灵活性。您可以完全控制请求和响应体的序列化方式。 + +## 请求序列化 + +\`serializeRequest\` 参数将请求对象转换为 \`HttpContent\`: + +```csharp +// 使用 System.Text.Json(推荐) +serializeRequest: body => JsonContent.Create(body) + +// 使用自定义选项 +serializeRequest: body => JsonContent.Create(body, options: new JsonSerializerOptions +{ + PropertyNamingPolicy = JsonNamingPolicy.CamelCase +}) + +// 自定义媒体类型 +serializeRequest: body => new StringContent( + JsonSerializer.Serialize(body), + Encoding.UTF8, + "application/vnd.api+json" +) +``` + +## 响应反序列化 + +\`deserializeSuccess\` 和 \`deserializeError\` 参数转换响应: + +```csharp +// 使用 System.Text.Json +deserializeSuccess: async (response, ct) => + await response.Content.ReadFromJsonAsync(ct) + +// 使用自定义选项 +deserializeSuccess: async (response, ct) => + await response.Content.ReadFromJsonAsync( + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, + ct + ) + +// 自定义反序列化 +deserializeSuccess: async (response, ct) => +{ + var json = await response.Content.ReadAsStringAsync(ct); + return MyCustomParser.Parse(json); +} +``` + +## 常见模式 + +### 可复用的反序列化器 + +定义可复用的反序列化器以避免重复: + +```csharp +public static class Deserializers +{ + public static async Task Json( + HttpResponseMessage response, + CancellationToken ct) => + await response.Content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("空响应"); + + public static async Task Error( + HttpResponseMessage response, + CancellationToken ct) => + await response.Content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("未知错误"); +} + +// 使用 +var result = await httpClient.GetAsync( + url: "https://api.example.com/users/123".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error +); +``` + +### 处理空响应 + +对于不返回响应体的端点(如 DELETE): + +```csharp +// 返回一个标记值表示成功 +deserializeSuccess: async (response, ct) => Unit.Value + +// 或返回状态码 +deserializeSuccess: async (response, ct) => response.StatusCode +``` + +### 二进制数据 + +对于文件下载或二进制响应: + +```csharp +deserializeSuccess: async (response, ct) => + await response.Content.ReadAsByteArrayAsync(ct) + +// 或作为流 +deserializeSuccess: async (response, ct) => + await response.Content.ReadAsStreamAsync(ct) +``` + +## 状态码处理 + +反序列化器接收完整的 \`HttpResponseMessage\`,因此您可以检查状态码: + +```csharp +deserializeSuccess: async (response, ct) => +{ + // 204 无内容 + if (response.StatusCode == HttpStatusCode.NoContent) + return default(User); + + return await response.Content.ReadFromJsonAsync(ct); +} +``` + +## 错误响应类型 + +您的错误类型可以是任何类型: + +```csharp +// 简单字符串 +deserializeError: async (response, ct) => + await response.Content.ReadAsStringAsync(ct) + +// 结构化错误 +public record ApiError(string Code, string Message, string[] Details); +deserializeError: async (response, ct) => + await response.Content.ReadFromJsonAsync(ct) + +// Problem Details (RFC 7807) +deserializeError: async (response, ct) => + await response.Content.ReadFromJsonAsync(ct) +``` diff --git a/Website/src/zh/api/url-handling.md b/Website/src/zh/api/url-handling.md new file mode 100644 index 00000000..f0fcdaa0 --- /dev/null +++ b/Website/src/zh/api/url-handling.md @@ -0,0 +1,292 @@ +--- +layout: layouts/docs.njk +title: URL 处理 +lang: zh +permalink: /zh/api/url-handling/ +eleventyNavigation: + key: URL 处理 + parent: API 参考 + order: 4 +--- + +# URL 处理 + +RestClient.Net 使用 \`AbsoluteUrl\` 类型进行类型安全的 URL 处理。本页介绍 URL 模式和工具。 + +## AbsoluteUrl 类型 + +\`AbsoluteUrl\` 类型确保 URL 在编译时有效: + +```csharp +using Urls; + +// 将字符串转换为 AbsoluteUrl +AbsoluteUrl url = "https://api.example.com/users/1".ToAbsoluteUrl(); + +// 在请求中使用 +var result = await httpClient.GetAsync( + url: url, + deserializeSuccess: Deserializers.User, + deserializeError: Deserializers.Error +); +``` + +## 字符串扩展 + +\`ToAbsoluteUrl()\` 扩展方法验证并转换字符串: + +```csharp +// 有效的 URL +var url1 = "https://api.example.com".ToAbsoluteUrl(); +var url2 = "http://localhost:5000/api".ToAbsoluteUrl(); + +// 无效的 URL 在运行时抛出异常 +var url3 = "not-a-url".ToAbsoluteUrl(); // 抛出异常! +var url4 = "/relative/path".ToAbsoluteUrl(); // 抛出异常! +``` + +## 路径参数 + +使用字符串插值处理路径参数: + +```csharp +var userId = "123"; +var url = \$"https://api.example.com/users/{userId}".ToAbsoluteUrl(); + +// 多个参数 +var orderId = "456"; +var url = \$"https://api.example.com/users/{userId}/orders/{orderId}".ToAbsoluteUrl(); +``` + +### URL 编码路径参数 + +对用户输入进行编码以防止注入: + +```csharp +var searchTerm = "foo/bar"; // 包含特殊字符 + +// 错误 - 可能破坏 URL +var badUrl = \$"https://api.example.com/search/{searchTerm}".ToAbsoluteUrl(); + +// 正确 - 正确编码 +var goodUrl = \$"https://api.example.com/search/{Uri.EscapeDataString(searchTerm)}" + .ToAbsoluteUrl(); +``` + +## 查询参数 + +### 简单查询字符串 + +```csharp +var page = 1; +var limit = 20; +var url = \$"https://api.example.com/users?page={page}&limit={limit}".ToAbsoluteUrl(); +``` + +### 带 URL 编码 + +```csharp +var query = "hello world"; +var url = \$"https://api.example.com/search?q={Uri.EscapeDataString(query)}" + .ToAbsoluteUrl(); +// 结果:https://api.example.com/search?q=hello%20world +``` + +### 查询字符串构建器 + +对于复杂的查询字符串,使用辅助方法: + +```csharp +public static class QueryString +{ + public static string Build(params (string key, string? value)[] parameters) + { + var pairs = parameters + .Where(p => p.value is not null) + .Select(p => \$"{Uri.EscapeDataString(p.key)}={Uri.EscapeDataString(p.value!)}"); + + return string.Join("&", pairs); + } +} + +// 使用 +var query = QueryString.Build( + ("q", searchTerm), + ("page", page.ToString()), + ("limit", limit.ToString()), + ("sort", sortBy) // 如果为 null 则省略 +); + +var url = \$"https://api.example.com/search?{query}".ToAbsoluteUrl(); +``` + +### 使用 QueryHelpers + +配合 Microsoft.AspNetCore.WebUtilities 使用: + +```csharp +using Microsoft.AspNetCore.WebUtilities; + +var parameters = new Dictionary +{ + ["q"] = searchTerm, + ["page"] = page.ToString(), + ["limit"] = limit.ToString(), +}; + +var url = QueryHelpers.AddQueryString("https://api.example.com/search", parameters) + .ToAbsoluteUrl(); +``` + +## 基础 URL + 相对路径 + +使用 \`IHttpClientFactory\` 时,将基础 URL 与相对路径组合: + +```csharp +// Program.cs +builder.Services.AddHttpClient("api", client => +{ + client.BaseAddress = new Uri("https://api.example.com/v1/"); +}); + +// 在您的服务中 +var client = httpClientFactory.CreateClient("api"); + +// 使用相对路径 +var result = await client.GetAsync( + url: "users/123".ToAbsoluteUrl(), // 解析为 https://api.example.com/v1/users/123 + deserializeSuccess: Deserializers.User, + deserializeError: Deserializers.Error +); +``` + +### URL 解析规则 + +```csharp +// 基础:https://api.example.com/v1/ + +"users" // -> https://api.example.com/v1/users +"users/123" // -> https://api.example.com/v1/users/123 +"/users" // -> https://api.example.com/users(覆盖路径!) +"../users" // -> https://api.example.com/users +``` + +## URL 构建器模式 + +为复杂场景创建流畅的 URL 构建器: + +```csharp +public class UrlBuilder +{ + private readonly string _baseUrl; + private readonly List _segments = []; + private readonly Dictionary _query = []; + + public UrlBuilder(string baseUrl) => _baseUrl = baseUrl.TrimEnd('/'); + + public UrlBuilder Segment(string segment) + { + _segments.Add(Uri.EscapeDataString(segment)); + return this; + } + + public UrlBuilder Query(string key, string? value) + { + if (value is not null) + _query[key] = value; + return this; + } + + public AbsoluteUrl Build() + { + var path = string.Join("/", _segments); + var query = _query.Count > 0 + ? "?" + string.Join("&", _query.Select(kv => + \$"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")) + : ""; + + return \$"{_baseUrl}/{path}{query}".ToAbsoluteUrl(); + } +} + +// 使用 +var url = new UrlBuilder("https://api.example.com") + .Segment("users") + .Segment(userId) + .Segment("orders") + .Query("status", "pending") + .Query("page", "1") + .Build(); +``` + +## 基于环境的 URL + +按环境配置 URL: + +```csharp +// appsettings.json +{ + "ApiSettings": { + "BaseUrl": "https://api.example.com" + } +} + +// appsettings.Development.json +{ + "ApiSettings": { + "BaseUrl": "https://localhost:5001" + } +} + +// Program.cs +var apiSettings = builder.Configuration.GetSection("ApiSettings").Get()!; + +builder.Services.AddHttpClient("api", client => +{ + client.BaseAddress = new Uri(apiSettings.BaseUrl); +}); +``` + +## URL 验证 + +### 编译时 + +\`AbsoluteUrl\` 类型提供类型安全: + +```csharp +public Task>> GetUserAsync( + AbsoluteUrl url, // 只接受有效的 AbsoluteUrl + CancellationToken ct = default); + +// 调用者必须将字符串转换为 AbsoluteUrl +var result = await GetUserAsync( + "https://api.example.com/users/1".ToAbsoluteUrl(), + ct +); +``` + +### 运行时验证 + +```csharp +public static bool TryCreateAbsoluteUrl(string input, out AbsoluteUrl? url) +{ + try + { + url = input.ToAbsoluteUrl(); + return true; + } + catch + { + url = null; + return false; + } +} +``` + +## 最佳实践 + +1. **始终编码用户输入** - 对路径和查询参数使用 \`Uri.EscapeDataString()\` +2. **使用基础 URL** - 在 \`IHttpClientFactory\` 中配置基础 URL +3. **安全构建复杂 URL** - 对多个参数使用 URL 构建器 +4. **验证 URL** - \`ToAbsoluteUrl()\` 对无效 URL 抛出异常 +5. **按环境配置** - 使用 appsettings 配置不同环境 diff --git a/Website/src/zh/blog/announcing-v6.md b/Website/src/zh/blog/announcing-v6.md new file mode 100644 index 00000000..f481dee8 --- /dev/null +++ b/Website/src/zh/blog/announcing-v6.md @@ -0,0 +1,55 @@ +--- +layout: layouts/blog.njk +title: RestClient.Net v6 发布 +date: 2024-03-15 +author: Christian Findlay +excerpt: v6 版本带来了 MCP 服务器生成、改进的错误处理和更好的性能。 +lang: zh +permalink: /zh/blog/announcing-v6/ +tags: + - zhposts + - release +--- + +# RestClient.Net v6 发布 + +我们很高兴宣布 RestClient.Net v6 的发布,这是迄今为止最大的更新! + +## 新功能 + +### MCP 服务器生成 + +现在你可以从 OpenAPI 规范生成模型上下文协议 (MCP) 服务器。这意味着你可以立即将 API 与 Claude Code 和其他 AI 工具集成。 + +```bash +dotnet run --project RestClient.Net.McpGenerator.Cli -- \ + --openapi-url api.yaml \ + --output-file Generated/McpTools.g.cs +``` + +### 改进的错误处理 + +v6 引入了更精细的错误类型,让你可以更精确地处理不同类型的失败: + +```csharp +var output = result switch +{ + OkPost(var post) => HandleSuccess(post), + ErrorPost(ResponseErrorPost(var err, var status, _)) => HandleApiError(err, status), + ErrorPost(ExceptionErrorPost(var ex)) => HandleException(ex), +}; +``` + +### 性能改进 + +- 减少内存分配 +- 更快的序列化 +- 更好的连接池利用 + +## 升级 + +```bash +dotnet add package RestClient.Net --version 6.0.0 +``` + +查看[完整文档](/zh/docs/)了解所有新功能! diff --git a/Website/src/zh/blog/functional-error-handling.md b/Website/src/zh/blog/functional-error-handling.md new file mode 100644 index 00000000..ec74685a --- /dev/null +++ b/Website/src/zh/blog/functional-error-handling.md @@ -0,0 +1,59 @@ +--- +layout: layouts/blog.njk +title: C# 中的函数式错误处理 +date: 2024-04-10 +author: Christian Findlay +excerpt: 了解 RestClient.Net 如何使用 Result 类型和可辨识联合实现函数式错误处理。 +lang: zh +permalink: /zh/blog/functional-error-handling/ +tags: + - zhposts + - tutorial +--- + +# C# 中的函数式错误处理 + +传统的 C# 错误处理依赖异常。但异常有问题: + +1. **不可见** - 你无法从方法签名知道它可能抛出什么 +2. **性能开销** - 抛出异常是昂贵的 +3. **容易遗漏** - 忘记处理异常意味着运行时崩溃 + +## Result 类型方法 + +RestClient.Net 使用 Result 类型来表示可能失败的操作: + +```csharp +// 返回类型清楚地表明这可能失败 +Result> result = await httpClient.GetAsync(...); +``` + +## 穷尽性检查的力量 + +使用 Exhaustion 分析器,遗漏任何情况都会导致编译错误: + +```csharp +// 这不会编译! +var output = result switch +{ + OkPost(var post) => "成功", + // 遗漏了 ErrorPost 情况 +}; +``` + +编译器会告诉你: + +``` +error EXHAUSTION001: Switch 不完整 +遗漏: ErrorPost +``` + +## 最佳实践 + +1. **总是使用类型别名** - 它们使模式匹配更加简洁 +2. **不要吞掉错误** - 始终处理所有情况 +3. **使用 Exhaustion** - 让编译器帮助你 + +## 总结 + +函数式错误处理使你的代码更安全、更可预测。RestClient.Net 让这在 C# 中变得简单! diff --git a/Website/src/zh/blog/introducing-restclient.md b/Website/src/zh/blog/introducing-restclient.md new file mode 100644 index 00000000..0133c202 --- /dev/null +++ b/Website/src/zh/blog/introducing-restclient.md @@ -0,0 +1,53 @@ +--- +layout: layouts/blog.njk +title: RestClient.Net 介绍 - C# 中的类型安全 REST 调用 +date: 2024-01-15 +author: Christian Findlay +excerpt: 了解 RestClient.Net 如何通过可辨识联合和穷尽性检查为 HTTP 调用带来函数式编程模式。 +lang: zh +permalink: /zh/blog/introducing-restclient/ +tags: + - zhposts + - announcement +--- + +# RestClient.Net 介绍 + +RestClient.Net 是一个现代的、类型安全的 C# REST 客户端,为 HTTP 通信带来函数式编程模式。 + +## 为什么需要另一个 REST 客户端? + +.NET 中的传统 HTTP 客户端有一个问题:它们会抛出异常。这迫使开发者使用 try/catch 块,使错误处理变得不一致且容易出错。 + +RestClient.Net 采用了不同的方法。每个 HTTP 调用返回一个必须显式处理的 `Result` 类型,而不是抛出异常。 + +## 可辨识联合的力量 + +```csharp +// 发起 GET 请求 +var result = await httpClient.GetAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializePost, + deserializeError: DeserializeError +); + +// 模式匹配 - 编译器强制你处理所有情况 +var output = result switch +{ + OkPost(var post) => $"成功: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"API 错误: {status}", + ErrorPost(ExceptionErrorPost(var ex)) => $"异常: {ex.Message}", +}; +``` + +## 穷尽性检查 + +使用 [Exhaustion 分析器](https://www.nuget.org/packages/Exhaustion),如果你遗漏了某个情况,代码将无法编译。不再有运行时惊喜。 + +## 开始使用 + +```bash +dotnet add package RestClient.Net +``` + +查看[文档](/zh/docs/)了解更多! diff --git a/Website/src/zh/blog/openapi-generator-released.md b/Website/src/zh/blog/openapi-generator-released.md new file mode 100644 index 00000000..1626c778 --- /dev/null +++ b/Website/src/zh/blog/openapi-generator-released.md @@ -0,0 +1,55 @@ +--- +layout: layouts/blog.njk +title: OpenAPI 生成器发布 +date: 2024-02-20 +author: Christian Findlay +excerpt: 从 OpenAPI 3.x 规范生成类型安全的 C# 客户端,包含自动 Result 类型别名。 +lang: zh +permalink: /zh/blog/openapi-generator-released/ +tags: + - zhposts + - release +--- + +# OpenAPI 生成器发布 + +我们很高兴宣布 RestClient.Net OpenAPI 生成器的发布! + +## 什么是 OpenAPI 生成器? + +OpenAPI 生成器从 OpenAPI 3.x 规范自动生成类型安全的 C# 客户端代码。生成的代码包含: + +- 所有端点的模型类 +- 使用 RestClient.Net Result 类型的 HttpClient 扩展方法 +- 用于简洁模式匹配的类型别名 + +## 安装 + +```bash +dotnet add package RestClient.Net.OpenApiGenerator +``` + +## 使用方法 + +```bash +dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \ + -u https://api.example.com/openapi.yaml \ + -o Generated \ + -n MyApi.Generated +``` + +## 生成的代码示例 + +```csharp +using MyApi.Generated; + +var result = await httpClient.GetUserById("123", ct); + +var message = result switch +{ + OkUser(var user) => $"找到用户: {user.Name}", + ErrorUser(var err) => $"错误: {err.StatusCode}", +}; +``` + +立即开始使用 OpenAPI 生成器! diff --git a/Website/src/zh/docs/advanced-usage.md b/Website/src/zh/docs/advanced-usage.md new file mode 100644 index 00000000..aefb2c19 --- /dev/null +++ b/Website/src/zh/docs/advanced-usage.md @@ -0,0 +1,409 @@ +--- +layout: layouts/docs.njk +title: 高级用法 +description: RestClient.Net 高级模式,包括重试策略、自定义中间件、身份验证处理器和依赖注入集成。 +keywords: RestClient.Net 高级, 重试策略, IHttpClientFactory, 身份验证处理器, 中间件 +lang: zh +permalink: /zh/docs/advanced-usage/ +eleventyNavigation: + key: 高级用法 + order: 5 +faq: + - question: 如何为 RestClient.Net 添加重试逻辑? + answer: 结合 IHttpClientFactory 使用 Polly 添加重试策略。为网络超时或 5xx 响应等瞬态错误配置重试策略。 + - question: 如何在依赖注入中使用 RestClient.Net? + answer: 使用 IHttpClientFactory 注册服务,配置命名或类型化客户端,然后将它们注入到服务中。 + - question: 如何使用 RestClient.Net 处理身份验证? + answer: 使用 DelegatingHandler 创建身份验证处理器,为请求添加令牌。结合 IHttpClientFactory 实现令牌刷新。 +--- + +# 高级用法 + +本指南涵盖 RestClient.Net 生产环境使用的高级模式。 + +## 使用 IHttpClientFactory + +在生产环境中始终使用 `IHttpClientFactory` 以避免套接字耗尽: + +```csharp +// Program.cs +builder.Services.AddHttpClient("api", client => +{ + client.BaseAddress = new Uri("https://api.example.com"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// 在服务中 +public class UserService(IHttpClientFactory httpClientFactory) +{ + public async Task>> GetUserAsync( + string userId, + CancellationToken ct = default) + { + var client = httpClientFactory.CreateClient("api"); + + return await client.GetAsync( + url: $"/users/{userId}".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error, + cancellationToken: ct + ); + } +} +``` + +## 类型化客户端 + +创建强类型客户端以便更好地组织代码: + +```csharp +// UserApiClient.cs +public class UserApiClient(HttpClient httpClient) +{ + public Task>> GetUserAsync( + string userId, + CancellationToken ct = default) => + httpClient.GetAsync( + url: $"/users/{userId}".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error, + cancellationToken: ct + ); + + public Task>> CreateUserAsync( + CreateUserRequest request, + CancellationToken ct = default) => + httpClient.PostAsync( + url: "/users".ToAbsoluteUrl(), + body: request, + serializeRequest: body => JsonContent.Create(body), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error, + cancellationToken: ct + ); +} + +// Program.cs +builder.Services.AddHttpClient(client => +{ + client.BaseAddress = new Uri("https://api.example.com"); +}); +``` + +## 使用 Polly 的重试策略 + +为瞬态故障添加重试逻辑: + +```csharp +using Microsoft.Extensions.Http.Resilience; + +// Program.cs +builder.Services.AddHttpClient("api") + .AddStandardResilienceHandler(options => + { + options.Retry.MaxRetryAttempts = 3; + options.Retry.Delay = TimeSpan.FromMilliseconds(500); + options.Retry.UseJitter = true; + options.Retry.ShouldHandle = args => ValueTask.FromResult( + args.Outcome.Exception is not null || + args.Outcome.Result?.StatusCode >= HttpStatusCode.InternalServerError + ); + }); +``` + +### 自定义重试策略 + +需要更多控制时: + +```csharp +builder.Services.AddHttpClient("api") + .AddPolicyHandler( + HttpPolicyExtensions + .HandleTransientHttpError() + .OrResult(msg => msg.StatusCode == HttpStatusCode.TooManyRequests) + .WaitAndRetryAsync( + retryCount: 3, + sleepDurationProvider: (retryAttempt, response, context) => + { + // 检查 Retry-After 头 + if (response.Result?.Headers.RetryAfter?.Delta is { } delta) + { + return delta; + } + // 指数退避 + return TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)); + }, + onRetryAsync: (outcome, timespan, retryAttempt, context) => + { + Console.WriteLine($"第 {retryAttempt} 次重试,等待 {timespan}"); + return Task.CompletedTask; + } + ) + ); +``` + +## 身份验证处理器 + +创建用于身份验证的委托处理器: + +```csharp +public class AuthenticationHandler(ITokenService tokenService) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var token = await tokenService.GetAccessTokenAsync(cancellationToken); + + request.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", token); + + return await base.SendAsync(request, cancellationToken); + } +} + +// Program.cs +builder.Services.AddTransient(); +builder.Services.AddHttpClient("api") + .AddHttpMessageHandler(); +``` + +### 令牌刷新处理器 + +自动刷新过期令牌: + +```csharp +public class TokenRefreshHandler( + ITokenService tokenService, + ILogger logger) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var token = await tokenService.GetAccessTokenAsync(cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var response = await base.SendAsync(request, cancellationToken); + + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + logger.LogInformation("令牌已过期,正在刷新..."); + + token = await tokenService.RefreshTokenAsync(cancellationToken); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + response = await base.SendAsync(request, cancellationToken); + } + + return response; + } +} +``` + +## 请求/响应日志 + +记录所有 HTTP 流量: + +```csharp +public class LoggingHandler(ILogger logger) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var requestId = Guid.NewGuid().ToString("N")[..8]; + + logger.LogInformation( + "[{RequestId}] {Method} {Uri}", + requestId, + request.Method, + request.RequestUri + ); + + var stopwatch = Stopwatch.StartNew(); + var response = await base.SendAsync(request, cancellationToken); + stopwatch.Stop(); + + logger.LogInformation( + "[{RequestId}] {StatusCode} 耗时 {ElapsedMs}ms", + requestId, + (int)response.StatusCode, + stopwatch.ElapsedMilliseconds + ); + + return response; + } +} +``` + +## 断路器 + +防止级联故障: + +```csharp +builder.Services.AddHttpClient("api") + .AddPolicyHandler( + HttpPolicyExtensions + .HandleTransientHttpError() + .CircuitBreakerAsync( + handledEventsAllowedBeforeBreaking: 5, + durationOfBreak: TimeSpan.FromSeconds(30), + onBreak: (result, duration) => + { + Console.WriteLine($"断路器打开,持续 {duration}"); + }, + onReset: () => + { + Console.WriteLine("断路器重置"); + } + ) + ); +``` + +## 自定义序列化 + +使用不同的序列化器: + +### System.Text.Json 带选项 + +```csharp +public static class Deserializers +{ + private static readonly JsonSerializerOptions Options = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public static async Task Json(HttpContent content, CancellationToken ct) + where T : class => + await content.ReadFromJsonAsync(Options, ct) + ?? throw new InvalidOperationException($"反序列化 {typeof(T).Name} 失败"); +} +``` + +### Newtonsoft.Json + +```csharp +public static async Task NewtonsoftJson(HttpContent content, CancellationToken ct) +{ + var json = await content.ReadAsStringAsync(ct); + return JsonConvert.DeserializeObject(json) + ?? throw new InvalidOperationException($"反序列化 {typeof(T).Name} 失败"); +} +``` + +## 缓存响应 + +缓存成功的响应: + +```csharp +public class CachingService( + IMemoryCache cache, + IHttpClientFactory httpClientFactory) +{ + public async Task>> GetUserAsync( + string userId, + CancellationToken ct = default) + { + var cacheKey = $"user:{userId}"; + + if (cache.TryGetValue(cacheKey, out var cached)) + { + return new Result>.Ok(cached); + } + + var client = httpClientFactory.CreateClient("api"); + + var result = await client.GetAsync( + url: $"/users/{userId}".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error, + cancellationToken: ct + ); + + if (result is Result>.Ok(var user)) + { + cache.Set(cacheKey, user, TimeSpan.FromMinutes(5)); + } + + return result; + } +} +``` + +## 请求关联 + +为分布式追踪添加关联 ID: + +```csharp +public class CorrelationHandler(IHttpContextAccessor httpContextAccessor) : DelegatingHandler +{ + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var correlationId = httpContextAccessor.HttpContext? + .Request.Headers["X-Correlation-ID"].FirstOrDefault() + ?? Guid.NewGuid().ToString(); + + request.Headers.Add("X-Correlation-ID", correlationId); + + return base.SendAsync(request, cancellationToken); + } +} +``` + +## 测试 + +### 模拟 HttpClient + +```csharp +public class UserServiceTests +{ + [Fact] + public async Task GetUser_ReturnsUser_WhenFound() + { + // 准备 + var handler = new MockHttpMessageHandler(request => + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(new User("1", "张三")) + }); + + var client = new HttpClient(handler) + { + BaseAddress = new Uri("https://api.example.com") + }; + + var service = new UserService(client); + + // 执行 + var result = await service.GetUserAsync("1"); + + // 断言 + Assert.IsType(result); + } +} +``` + +## 最佳实践总结 + +1. **在生产环境中始终使用 IHttpClientFactory** +2. **明确配置超时** +3. **为瞬态错误添加重试策略** +4. **使用断路器防止级联故障** +5. **实现身份验证处理器** 进行令牌管理 +6. **记录请求和响应日志** 用于调试 +7. **添加关联 ID** 用于分布式追踪 +8. **在适当时缓存昂贵的操作** +9. **使用模拟处理器测试** 以确保可靠性 + +## 下一步 + +- [OpenAPI 生成器](/zh/docs/openapi/) - 自动生成客户端 +- [MCP 服务器](/zh/docs/mcp/) - Claude Code 集成 +- [API 参考](/zh/api/) - 完整文档 diff --git a/Website/src/zh/docs/api/httpclient-extensions.md b/Website/src/zh/docs/api/httpclient-extensions.md new file mode 100644 index 00000000..c3998e5e --- /dev/null +++ b/Website/src/zh/docs/api/httpclient-extensions.md @@ -0,0 +1,341 @@ +--- +layout: layouts/docs.njk +title: HttpClient 扩展 +lang: zh +permalink: /zh/docs/api/httpclient-extensions/ +eleventyNavigation: + key: HttpClient 扩展 + parent: API 参考 + order: 1 +--- + +# HttpClient 扩展 + +`System.Net.Http.HttpClient` 的扩展方法,返回 `Result>` 而不是抛出异常。 + +**命名空间:** `RestClient.Net` + +## SendAsync + +发送 HTTP 请求的核心方法,可完全控制所有参数。 + +```csharp +public static async Task>> SendAsync( + this HttpClient httpClient, + AbsoluteUrl url, + HttpMethod httpMethod, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + HttpContent? requestBody = null, + HttpAction? httpOperation = null, + CancellationToken cancellationToken = default +) +``` + +### 类型参数 + +| 参数 | 描述 | +|-----------|-------------| +| `TSuccess` | 成功响应(2xx)反序列化的目标类型 | +| `TError` | 错误响应(非 2xx)反序列化的目标类型 | + +### 参数 + +| 参数 | 类型 | 描述 | +|-----------|------|-------------| +| `httpClient` | `HttpClient` | HTTP 客户端实例 | +| `url` | `AbsoluteUrl` | 请求的绝对 URL | +| `httpMethod` | `HttpMethod` | HTTP 方法(GET、POST、PUT 等) | +| `deserializeSuccess` | `Deserialize` | 反序列化成功响应的异步函数 | +| `deserializeError` | `Deserialize` | 反序列化错误响应的异步函数 | +| `headers` | `IReadOnlyDictionary?` | 可选的请求头 | +| `requestBody` | `HttpContent?` | 可选的请求体内容 | +| `httpOperation` | `HttpAction?` | 可选的自定义 HTTP 操作处理器 | +| `cancellationToken` | `CancellationToken` | 取消令牌 | + +### 返回值 + +`Task>>` - 结果为以下之一: +- `Ok` 包含 2xx 状态码的反序列化响应 +- `Error>` 包含 `ErrorResponseError` 或 `ExceptionError` + +--- + +## GetAsync + +执行 GET 请求。 + +```csharp +public static Task>> GetAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +### 示例 + +```csharp +var result = await httpClient.GetAsync( + url: "https://api.example.com/users/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson +); + +var output = result switch +{ + Result>.Ok>(var user) + => $"找到: {user.Name}", + Result>.Error>(var error) + => $"发生错误", +}; +``` + +--- + +## PostAsync + +执行带请求体的 POST 请求。 + +```csharp +public static Task>> PostAsync( + this HttpClient httpClient, + AbsoluteUrl url, + HttpContent? requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +### 示例 + +```csharp +var content = new StringContent( + JsonSerializer.Serialize(new { Name = "张三", Email = "zhangsan@example.com" }), + Encoding.UTF8, + "application/json" +); + +var result = await httpClient.PostAsync( + url: "https://api.example.com/users".ToAbsoluteUrl(), + requestBody: content, + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson +); +``` + +--- + +## PutAsync + +执行带请求体的 PUT 请求。 + +```csharp +public static Task>> PutAsync( + this HttpClient httpClient, + AbsoluteUrl url, + HttpContent? requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## DeleteAsync + +执行 DELETE 请求。 + +```csharp +public static Task>> DeleteAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## PatchAsync + +执行带请求体的 PATCH 请求。 + +```csharp +public static Task>> PatchAsync( + this HttpClient httpClient, + AbsoluteUrl url, + HttpContent? requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## HeadAsync + +执行 HEAD 请求(仅获取响应头)。 + +```csharp +public static Task>> HeadAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## OptionsAsync + +执行 OPTIONS 请求(获取允许的方法)。 + +```csharp +public static Task>> OptionsAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## DownloadFileAsync + +从指定 URL 下载文件到流。 + +```csharp +public static Task>> DownloadFileAsync( + this HttpClient httpClient, + AbsoluteUrl url, + Stream destinationStream, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +### 示例 + +```csharp +using var fileStream = File.Create("downloaded-file.pdf"); +var result = await httpClient.DownloadFileAsync( + url: "https://api.example.com/files/report.pdf".ToAbsoluteUrl(), + destinationStream: fileStream, + deserializeError: DeserializeJson +); + +if (result.IsOk) +{ + Console.WriteLine("文件下载成功"); +} +``` + +--- + +## UploadFileAsync + +上传文件到指定 URL。 + +```csharp +public static Task>> UploadFileAsync( + this HttpClient httpClient, + AbsoluteUrl url, + HttpContent requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +### 示例 + +```csharp +using var fileStream = File.OpenRead("document.pdf"); +var content = new ProgressReportingHttpContent( + content: fileStream, + progress: (current, total) => Console.WriteLine($"进度: {current}/{total}") +); + +var result = await httpClient.UploadFileAsync( + url: "https://api.example.com/upload".ToAbsoluteUrl(), + requestBody: content, + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson +); +``` + +--- + +## 请求工厂方法 + +这些方法创建可多次调用的可复用请求委托。 + +### CreateGet + +创建 GET 请求委托。 + +```csharp +public static GetAsync CreateGet( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreatePost + +创建 POST 请求委托。 + +### CreatePut + +创建 PUT 请求委托。 + +### CreateDelete + +创建 DELETE 请求委托。 + +### CreatePatch + +创建 PATCH 请求委托。 + +### 工厂示例 + +```csharp +// 定义一次请求工厂 +var getUser = HttpClientExtensions.CreateGet( + url: "https://api.example.com".ToAbsoluteUrl(), + buildRequest: userId => new HttpRequestParts( + RelativeUrl: $"/users/{userId}".ToRelativeUrl(), + Body: null, + Headers: null + ), + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson +); + +// 多次使用 +var user1 = await getUser(httpClient, 1, CancellationToken.None); +var user2 = await getUser(httpClient, 2, CancellationToken.None); +``` diff --git a/Website/src/zh/docs/api/httpclientfactory-extensions.md b/Website/src/zh/docs/api/httpclientfactory-extensions.md new file mode 100644 index 00000000..7bfd93bc --- /dev/null +++ b/Website/src/zh/docs/api/httpclientfactory-extensions.md @@ -0,0 +1,275 @@ +--- +layout: layouts/docs.njk +title: IHttpClientFactory 扩展 +lang: zh +permalink: /zh/docs/api/httpclientfactory-extensions/ +eleventyNavigation: + key: IHttpClientFactory 扩展 + parent: API 参考 + order: 2 +--- + +# IHttpClientFactory 扩展 + +`System.Net.Http.IHttpClientFactory` 的扩展方法,提供与 HttpClient 扩展相同的类型安全 HTTP 操作,并内置客户端创建和连接池管理。 + +**命名空间:** `RestClient.Net` + +## 概述 + +这些扩展方法与 Microsoft.Extensions.Http 中的 `IHttpClientFactory` 配合使用,在 ASP.NET Core 应用程序中实现正确的 HTTP 连接管理。 + +## SendAsync + +发送 HTTP 请求的核心方法,可完全控制所有参数。 + +```csharp +public static async Task>> SendAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + HttpMethod httpMethod, + Deserialize deserializeSuccess, + Deserialize deserializeError, + HttpContent? requestBody = null, + IReadOnlyDictionary? headers = null, + HttpAction? httpOperation = null, + CancellationToken cancellationToken = default +) +``` + +### 参数 + +| 参数 | 类型 | 描述 | +|-----------|------|-------------| +| `httpClientFactory` | `IHttpClientFactory` | HTTP 客户端工厂实例 | +| `clientName` | `string` | 已配置的 HTTP 客户端名称 | +| `url` | `AbsoluteUrl` | 请求的绝对 URL | +| `httpMethod` | `HttpMethod` | HTTP 方法(GET、POST、PUT 等) | +| `deserializeSuccess` | `Deserialize` | 反序列化成功响应的异步函数 | +| `deserializeError` | `Deserialize` | 反序列化错误响应的异步函数 | +| `requestBody` | `HttpContent?` | 可选的请求体内容 | +| `headers` | `IReadOnlyDictionary?` | 可选的请求头 | +| `httpOperation` | `HttpAction?` | 可选的自定义 HTTP 操作处理器 | +| `cancellationToken` | `CancellationToken` | 取消令牌 | + +--- + +## GetAsync + +使用命名 HTTP 客户端执行 GET 请求。 + +```csharp +public static Task>> GetAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +### 示例 + +```csharp +// 在 Program.cs 中配置命名客户端 +builder.Services.AddHttpClient("api", client => +{ + client.BaseAddress = new Uri("https://api.example.com"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); +}); + +// 在服务中使用工厂 +public class UserService(IHttpClientFactory factory) +{ + public async Task>> GetUserAsync(int id) + { + return await factory.GetAsync( + clientName: "api", + url: $"https://api.example.com/users/{id}".ToAbsoluteUrl(), + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson + ); + } +} +``` + +--- + +## PostAsync + +执行带请求体的 POST 请求。 + +```csharp +public static Task>> PostAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + HttpContent? requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## PutAsync + +执行带请求体的 PUT 请求。 + +```csharp +public static Task>> PutAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + HttpContent? requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## DeleteAsync + +执行 DELETE 请求。 + +```csharp +public static Task>> DeleteAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## PatchAsync + +执行带请求体的 PATCH 请求。 + +```csharp +public static Task>> PatchAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + HttpContent requestBody, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## DownloadFileAsync + +从指定 URL 下载文件到流。 + +```csharp +public static Task>> DownloadFileAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + Stream destinationStream, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## UploadFileAsync + +上传带进度报告的文件。 + +```csharp +public static Task>> UploadFileAsync( + this IHttpClientFactory httpClientFactory, + string clientName, + AbsoluteUrl url, + ProgressReportingHttpContent fileStream, + Deserialize deserializeSuccess, + Deserialize deserializeError, + IReadOnlyDictionary? headers = null, + CancellationToken cancellationToken = default +) +``` + +--- + +## 请求工厂方法 + +这些静态方法创建可复用的请求委托。 + +### CreateGet + +```csharp +public static GetAsync CreateGet( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreatePost + +```csharp +public static PostAsync CreatePost( + AbsoluteUrl url, + BuildRequest buildRequest, + Deserialize deserializeSuccess, + Deserialize deserializeError +) +``` + +### CreatePut / CreateDelete / CreatePatch / CreateHead / CreateOptions + +类似的工厂方法用于其他 HTTP 方法。 + +--- + +## 配置示例 + +```csharp +// Program.cs +var builder = WebApplication.CreateBuilder(args); + +// 配置命名 HTTP 客户端 +builder.Services.AddHttpClient("github", client => +{ + client.BaseAddress = new Uri("https://api.github.com"); + client.DefaultRequestHeaders.Add("Accept", "application/vnd.github.v3+json"); + client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0"); +}); + +builder.Services.AddHttpClient("internal-api", client => +{ + client.BaseAddress = new Uri("https://internal.example.com"); +}) +.AddPolicyHandler(GetRetryPolicy()); // 添加 Polly 策略 + +// 注册使用工厂的服务 +builder.Services.AddScoped(); +``` + +## 最佳实践 + +1. **使用命名客户端** - 为不同的 API 配置不同的客户端 +2. **配置基础地址** - 在客户端配置中设置 `BaseAddress` +3. **添加默认请求头** - 配置常用请求头如 Accept、Authorization +4. **使用 Polly** - 添加重试和断路器策略以提高弹性 +5. **避免捕获 HttpClient** - 让工厂根据需要创建新客户端 diff --git a/Website/src/zh/docs/api/index.md b/Website/src/zh/docs/api/index.md new file mode 100644 index 00000000..f66eee4d --- /dev/null +++ b/Website/src/zh/docs/api/index.md @@ -0,0 +1,81 @@ +--- +layout: layouts/docs.njk +title: API 参考 +lang: zh +permalink: /zh/docs/api/ +eleventyNavigation: + key: API 参考 + parent: 文档 + order: 1 +--- + +# API 参考 + +RestClient.Net 和 Outcome 库的完整参考文档。 + +## 核心包 + +### RestClient.Net + +主库,提供带有可辨识联合返回类型的类型安全 HTTP 操作。 + +- [HttpClient 扩展](/zh/docs/api/httpclient-extensions/) - `HttpClient` 的扩展方法 +- [IHttpClientFactory 扩展](/zh/docs/api/httpclientfactory-extensions/) - `IHttpClientFactory` 的扩展方法 +- [委托](/zh/docs/api/delegates/) - 用于序列化/反序列化的函数委托 +- [工具类](/zh/docs/api/utilities/) - 辅助类如 `ProgressReportingHttpContent` + +### Outcome + +提供用于错误处理的可辨识联合的函数式编程库。 + +- [Result 类型](/zh/docs/api/result/) - 核心 `Result` 类型 +- [HttpError 类型](/zh/docs/api/httperror/) - HTTP 特定的错误表示 +- [Result 扩展](/zh/docs/api/result-extensions/) - `Result` 的扩展方法 +- [Unit 类型](/zh/docs/api/unit/) - 函数式编程中的 void 等价物 + +### 代码生成器 + +从 API 规范生成类型安全客户端的工具。 + +- [OpenAPI 生成器](/zh/docs/api/openapi-generator/) - 从 OpenAPI/Swagger 规范生成 C# 客户端 +- [MCP 生成器](/zh/docs/api/mcp-generator/) - 从 OpenAPI 规范生成模型上下文协议服务器 + +## 快速入门 + +```csharp +using RestClient.Net; +using Outcome; + +// 发起类型安全的 GET 请求 +var result = await httpClient.GetAsync( + url: "https://api.example.com/users/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializeJson, + deserializeError: DeserializeJson +); + +// 使用模式匹配处理结果 +var message = result switch +{ + Result>.Ok>(var user) + => $"找到用户: {user.Name}", + Result>.Error>(var error) + => error switch + { + HttpError.ErrorResponseError(var body, var status, _) + => $"API 错误 {status}: {body.Message}", + HttpError.ExceptionError(var ex) + => $"异常: {ex.Message}", + _ => "未知错误" + }, + _ => "意外情况" +}; +``` + +## 设计理念 + +RestClient.Net 遵循铁路导向编程原则: + +1. **预期错误不使用异常** - HTTP 错误作为值返回,而不是抛出 +2. **完整的错误处理** - 模式匹配确保处理所有情况 +3. **类型安全** - 泛型参数提供编译时类型检查 +4. **可组合性** - Result 可以函数式地进行映射、绑定和组合 diff --git a/Website/src/zh/docs/basic-usage.md b/Website/src/zh/docs/basic-usage.md new file mode 100644 index 00000000..e9793bd8 --- /dev/null +++ b/Website/src/zh/docs/basic-usage.md @@ -0,0 +1,261 @@ +--- +layout: layouts/docs.njk +title: 基本用法 +description: 学习使用 RestClient.Net 进行类型安全 REST 调用的基础知识。包含 GET、POST、PUT、DELETE 示例及正确的错误处理。 +keywords: RestClient.Net 教程, C# REST 客户端, HttpClient 扩展, 类型安全 HTTP 调用 +lang: zh +permalink: /zh/docs/basic-usage/ +eleventyNavigation: + key: 基本用法 + order: 3 +faq: + - question: 如何使用 RestClient.Net 发起 GET 请求? + answer: 使用 HttpClient 上的 GetAsync 扩展方法,传入 URL、成功反序列化器和错误反序列化器。返回的结果是一个必须进行模式匹配的可辨识联合。 + - question: ToAbsoluteUrl() 扩展是什么? + answer: ToAbsoluteUrl() 将字符串转换为 AbsoluteUrl 类型,在编译时验证 URL 格式并提供类型安全。 + - question: 为什么需要反序列化器函数? + answer: 反序列化器函数允许您精确控制响应的解析方式,支持任何序列化格式(JSON、XML 等)和自定义验证。 +--- + +# 基本用法 + +本指南涵盖使用 RestClient.Net 进行 REST 调用的基础知识。 + +## 第一个请求 + +让我们发起一个简单的 GET 请求来获取帖子: + +```csharp +using System.Net.Http.Json; +using RestClient.Net; +using Urls; + +// 定义模型 +record Post(int UserId, int Id, string Title, string Body); +record ErrorResponse(string Message); + +// 创建 HttpClient(在生产环境中使用 IHttpClientFactory) +using var httpClient = new HttpClient(); + +// 发起请求 +var result = await httpClient.GetAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("空响应"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("未知错误") +); + +// 处理结果 - 必须处理所有情况 +var message = result switch +{ + Outcome.Result>.Ok(var post) => + $"成功: {post.Title}", + + Outcome.Result>.Error( + Outcome.HttpError.ResponseError(var err, var status, _)) => + $"API 错误 {status}: {err.Message}", + + Outcome.Result>.Error( + Outcome.HttpError.ExceptionError(var ex)) => + $"异常: {ex.Message}", +}; + +Console.WriteLine(message); +``` + +## 使用类型别名 + +完整类型名称很冗长。在 `GlobalUsings.cs` 中添加类型别名: + +```csharp +// GlobalUsings.cs +global using OkPost = Outcome.Result> + .Ok>; + +global using ErrorPost = Outcome.Result> + .Error>; + +global using ResponseErrorPost = Outcome.HttpError.ErrorResponseError; + +global using ExceptionErrorPost = Outcome.HttpError.ExceptionError; +``` + +现在模式匹配更简洁了: + +```csharp +var message = result switch +{ + OkPost(var post) => $"成功: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"API 错误 {status}: {err.Message}", + ErrorPost(ExceptionErrorPost(var ex)) => $"异常: {ex.Message}", +}; +``` + +## POST 请求 + +使用 POST 创建新资源: + +```csharp +record CreatePostRequest(string Title, string Body, int UserId); + +var newPost = new CreatePostRequest("我的标题", "我的内容", 1); + +var result = await httpClient.PostAsync( + url: "https://jsonplaceholder.typicode.com/posts".ToAbsoluteUrl(), + body: newPost, + serializeRequest: body => JsonContent.Create(body), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("空响应"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("未知错误") +); +``` + +## PUT 请求 + +替换现有资源: + +```csharp +record UpdatePostRequest(int Id, string Title, string Body, int UserId); + +var updatedPost = new UpdatePostRequest(1, "更新的标题", "更新的内容", 1); + +var result = await httpClient.PutAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + body: updatedPost, + serializeRequest: body => JsonContent.Create(body), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("空响应"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("未知错误") +); +``` + +## DELETE 请求 + +删除资源: + +```csharp +record DeleteResponse(bool Success); + +var result = await httpClient.DeleteAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: async (content, ct) => + new DeleteResponse(true), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("未知错误") +); +``` + +## PATCH 请求 + +部分更新资源: + +```csharp +record PatchPostRequest(string? Title = null, string? Body = null); + +var patch = new PatchPostRequest(Title: "只更新标题"); + +var result = await httpClient.PatchAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + body: patch, + serializeRequest: body => JsonContent.Create(body), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException("空响应"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("未知错误") +); +``` + +## 可复用的反序列化器 + +创建可复用的反序列化器方法: + +```csharp +public static class Deserializers +{ + public static async Task Json(HttpContent content, CancellationToken ct) + where T : class => + await content.ReadFromJsonAsync(ct) + ?? throw new InvalidOperationException($"反序列化 {typeof(T).Name} 失败"); + + public static async Task Error(HttpContent content, CancellationToken ct) => + await content.ReadFromJsonAsync(ct) + ?? new ErrorResponse("未知错误"); +} + +// 使用方式 +var result = await httpClient.GetAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error +); +``` + +## 添加请求头 + +在 HttpClient 上设置请求头: + +```csharp +httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", "your-token"); + +httpClient.DefaultRequestHeaders.Add("X-Custom-Header", "value"); + +var result = await httpClient.GetAsync(...); +``` + +## 查询参数 + +使用查询参数构建 URL: + +```csharp +// 使用字符串插值 +var url = $"https://api.example.com/posts?userId={userId}&page={page}".ToAbsoluteUrl(); + +// 或使用 URL 构建库 +var result = await httpClient.GetAsync( + url: url, + deserializeSuccess: Deserializers.Json>, + deserializeError: Deserializers.Error +); +``` + +## 取消操作 + +传递 `CancellationToken` 以支持可取消的请求: + +```csharp +var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + +var result = await httpClient.GetAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error, + cancellationToken: cts.Token +); +``` + +## 最佳实践 + +1. **在生产环境中使用 IHttpClientFactory** 以实现正确的连接池管理 +2. **在 GlobalUsings.cs 中定义类型别名** 以保持代码简洁 +3. **创建可复用的反序列化器** 以避免重复 +4. **始终处理所有情况** 在 switch 表达式中 +5. **使用取消令牌** 用于面向用户的请求 + +## 下一步 + +- [错误处理](/zh/docs/error-handling/) - 深入了解 Result 类型 +- [高级用法](/zh/docs/advanced-usage/) - 重试策略、中间件等 +- [API 参考](/zh/api/) - 完整的方法文档 diff --git a/Website/src/zh/docs/error-handling.md b/Website/src/zh/docs/error-handling.md new file mode 100644 index 00000000..7e1b540a --- /dev/null +++ b/Website/src/zh/docs/error-handling.md @@ -0,0 +1,362 @@ +--- +layout: layouts/docs.njk +title: 错误处理 +description: 掌握 RestClient.Net Result 类型的函数式错误处理。学习 C# REST 调用的模式匹配、可辨识联合和穷尽性检查。 +keywords: C# 错误处理, Result 类型, 可辨识联合, 模式匹配, 穷尽性检查 +lang: zh +permalink: /zh/docs/error-handling/ +eleventyNavigation: + key: 错误处理 + order: 4 +faq: + - question: RestClient.Net 中的 Result 类型是什么? + answer: Result 类型是一个可辨识联合,表示成功(Ok)或失败(Error)。它强制您在编译时处理所有可能的结果。 + - question: 什么是穷尽性检查? + answer: Exhaustion 分析器验证您的 switch 表达式处理了所有可能的情况。遗漏的情况会导致编译错误,而不是运行时崩溃。 + - question: ResponseError 和 ExceptionError 有什么区别? + answer: ResponseError 在服务器返回错误状态码(4xx、5xx)及响应体时发生。ExceptionError 在抛出异常时发生(网络错误、超时等)。 +--- + +# 错误处理 + +RestClient.Net 使用可辨识联合和穷尽性检查来使错误处理安全且明确。本指南解释相关模式和最佳实践。 + +## 传统错误处理的问题 + +传统 HTTP 客户端会抛出异常: + +```csharp +// 传统方式 - 危险! +try +{ + var response = await httpClient.GetAsync("https://api.example.com/user/1"); + response.EnsureSuccessStatusCode(); // 出错时抛出异常! + var user = await response.Content.ReadFromJsonAsync(); +} +catch (HttpRequestException ex) +{ + // 网络错误 +} +catch (JsonException ex) +{ + // 反序列化错误 +} +catch (Exception ex) +{ + // 还可能发生什么?谁知道! +} +``` + +问题: +- 类型签名不会告诉你可能抛出什么 +- 容易忘记 catch 块 +- 正常路径和错误路径结构不同 +- 遗漏异常类型会导致运行时崩溃 + +## RestClient.Net 方式 + +每个操作都返回 `Result>`: + +```csharp +// 类型告诉你所有可能发生的事情 +Result> result = await httpClient.GetAsync(...); + +// 模式匹配确保处理所有情况 +var output = result switch +{ + OkUser(var user) => user.Name, + ErrorUser(ResponseErrorUser(var err, var status, _)) => $"API 错误 {status}", + ErrorUser(ExceptionErrorUser(var ex)) => $"异常: {ex.Message}", +}; +``` + +## 理解 Result 类型 + +### Result + +基础可辨识联合: + +```csharp +public abstract record Result +{ + public sealed record Ok(TSuccess Value) : Result; + public sealed record Error(TError Value) : Result; +} +``` + +### HttpError + +HTTP 操作的错误类型: + +```csharp +public abstract record HttpError +{ + // 服务器返回错误响应(4xx、5xx) + public sealed record ResponseError( + TError Error, + HttpStatusCode StatusCode, + HttpResponseMessage Response + ) : HttpError; + + // 发生异常(网络错误、超时等) + public sealed record ExceptionError( + Exception Exception + ) : HttpError; +} +``` + +## 穷尽性检查 + +Exhaustion 分析器确保您处理所有情况: + +```csharp +// 这段代码无法编译! +var message = result switch +{ + OkUser(var user) => "成功", + ErrorUser(ResponseErrorUser(...)) => "响应错误", + // 编译错误:缺少 ExceptionErrorUser +}; +``` + +错误消息会精确告诉你缺少什么: + +``` +error EXHAUSTION001: Switch on Result is not exhaustive; +Matched: Ok>, Error> with ErrorResponseError +Missing: Error> with ExceptionError +``` + +## 模式匹配模式 + +### 基本模式 + +明确处理所有三种情况: + +```csharp +var message = result switch +{ + OkUser(var user) => $"欢迎,{user.Name}", + ErrorUser(ResponseErrorUser(var err, var status, _)) => + $"服务器错误 {(int)status}: {err.Message}", + ErrorUser(ExceptionErrorUser(var ex)) => + $"网络错误: {ex.Message}", +}; +``` + +### 状态码匹配 + +对不同状态码进行不同处理: + +```csharp +var message = result switch +{ + OkUser(var user) => $"找到: {user.Name}", + + ErrorUser(ResponseErrorUser(_, HttpStatusCode.NotFound, _)) => + "用户未找到", + + ErrorUser(ResponseErrorUser(_, HttpStatusCode.Unauthorized, _)) => + "请登录", + + ErrorUser(ResponseErrorUser(var err, var status, _)) => + $"服务器错误 {(int)status}: {err.Message}", + + ErrorUser(ExceptionErrorUser(var ex)) => + $"网络错误: {ex.Message}", +}; +``` + +### 访问完整响应 + +`ResponseError` 包含完整的 `HttpResponseMessage`: + +```csharp +var message = result switch +{ + OkUser(var user) => user.Name, + + ErrorUser(ResponseErrorUser(var err, var status, var response)) => + { + // 访问响应头 + if (response.Headers.TryGetValues("X-Rate-Limit-Remaining", out var values)) + { + Console.WriteLine($"剩余请求限制: {values.First()}"); + } + return $"错误: {err.Message}"; + }, + + ErrorUser(ExceptionErrorUser(var ex)) => $"异常: {ex.Message}", +}; +``` + +### 异常类型匹配 + +处理特定异常类型: + +```csharp +var message = result switch +{ + OkUser(var user) => user.Name, + + ErrorUser(ResponseErrorUser(var err, _, _)) => err.Message, + + ErrorUser(ExceptionErrorUser(TaskCanceledException ex)) when ex.CancellationToken.IsCancellationRequested => + "请求已取消", + + ErrorUser(ExceptionErrorUser(TaskCanceledException)) => + "请求超时", + + ErrorUser(ExceptionErrorUser(HttpRequestException)) => + "网络连接问题", + + ErrorUser(ExceptionErrorUser(var ex)) => + $"意外错误: {ex.Message}", +}; +``` + +## 常见错误处理模式 + +### 出错时返回默认值 + +发生任何错误时返回默认值: + +```csharp +User user = result switch +{ + OkUser(var u) => u, + _ => User.Guest, +}; +``` + +### 出错时抛出异常(逃生舱口) + +当确实无法处理错误时: + +```csharp +User user = result switch +{ + OkUser(var u) => u, + ErrorUser(var error) => throw new InvalidOperationException($"获取用户失败: {error}"), +}; +``` + +### 转换为可空类型 + +用于可选数据: + +```csharp +User? user = result switch +{ + OkUser(var u) => u, + _ => null, +}; + +if (user is not null) +{ + // 使用 user +} +``` + +### 记录错误日志 + +记录错误同时仍然处理它们: + +```csharp +var message = result switch +{ + OkUser(var user) => user.Name, + + ErrorUser(ResponseErrorUser(var err, var status, _)) => + { + logger.LogWarning("API 返回 {Status}: {Error}", status, err.Message); + return "服务暂时不可用"; + }, + + ErrorUser(ExceptionErrorUser(var ex)) => + { + logger.LogError(ex, "发生网络错误"); + return "连接失败"; + }, +}; +``` + +## 链式操作 + +### Map - 转换成功值 + +在不触及错误的情况下转换成功值: + +```csharp +// 将 Result> 转换为 Result> +var nameResult = userResult.Map(user => user.Name); +``` + +### FlatMap / Bind - 链接异步操作 + +链接每个都返回 Result 的操作: + +```csharp +// 获取用户,然后获取其订单 +var ordersResult = await userResult + .FlatMapAsync(user => GetOrdersAsync(user.Id)); +``` + +### 聚合多个结果 + +组合多个 Result: + +```csharp +var userResult = await GetUserAsync(userId); +var ordersResult = await GetOrdersAsync(userId); +var settingsResult = await GetSettingsAsync(userId); + +var combined = (userResult, ordersResult, settingsResult) switch +{ + (OkUser(var user), OkOrders(var orders), OkSettings(var settings)) => + new UserDashboard(user, orders, settings), + + (ErrorUser(var e), _, _) => throw new Exception($"用户错误: {e}"), + (_, ErrorOrders(var e), _) => throw new Exception($"订单错误: {e}"), + (_, _, ErrorSettings(var e)) => throw new Exception($"设置错误: {e}"), +}; +``` + +## 错误响应模型 + +为您的 API 定义清晰的错误模型: + +```csharp +// 简单错误 +record ApiError(string Message, string Code); + +// 带验证的详细错误 +record ValidationError( + string Message, + Dictionary Errors +); + +// 标准问题详情(RFC 7807) +record ProblemDetails( + string Type, + string Title, + int Status, + string Detail, + string Instance +); +``` + +## 最佳实践 + +1. **定义类型别名** 以获得更简洁的模式匹配 +2. **始终处理所有情况** - 编译器会强制执行 +3. **具体处理错误** - 不要只是捕获所有内容 +4. **记录错误日志** 然后再返回用户友好的消息 +5. **使用响应对象** 获取请求头和高级场景 +6. **在适当时链接操作** 使用 Map 和 FlatMap + +## 下一步 + +- [高级用法](/zh/docs/advanced-usage/) - 重试策略和中间件 +- [Exhaustion 分析器](/zh/docs/exhaustion/) - 深入了解穷尽性检查 +- [API 参考](/zh/api/result-types/) - 完整的 Result 类型文档 diff --git a/Website/src/zh/docs/exhaustion.md b/Website/src/zh/docs/exhaustion.md new file mode 100644 index 00000000..7b754d8b --- /dev/null +++ b/Website/src/zh/docs/exhaustion.md @@ -0,0 +1,43 @@ +--- +layout: layouts/docs.njk +title: Exhaustion 分析器 +lang: zh +permalink: /zh/docs/exhaustion/ +eleventyNavigation: + key: Exhaustion + order: 4 +--- + +# 穷尽性检查 + +Exhaustion 分析器确保你在模式匹配时处理每种可能的情况。 + +## 不使用 Exhaustion(危险) + +```csharp +// 编译通过但可能在运行时崩溃! +var output = result switch +{ + OkPost(var post) => $"成功: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"错误 {status}", + // 遗漏了 ExceptionErrorPost 情况! +}; +``` + +## 使用 Exhaustion(安全) + +``` +error EXHAUSTION001: Result 上的 Switch 不完整; +已匹配: Ok>, Error> with ErrorResponseError +遗漏: Error> with ExceptionError +``` + +你的构建将失败,直到处理所有情况。**运行时崩溃变成编译时错误。** + +## 安装 + +Exhaustion 会随 RestClient.Net 自动安装,或单独安装: + +```bash +dotnet add package Exhaustion +``` diff --git a/Website/src/zh/docs/index.md b/Website/src/zh/docs/index.md new file mode 100644 index 00000000..1f88e6f1 --- /dev/null +++ b/Website/src/zh/docs/index.md @@ -0,0 +1,61 @@ +--- +layout: layouts/docs.njk +title: 入门指南 +lang: zh +permalink: /zh/docs/ +eleventyNavigation: + key: 入门指南 + order: 1 +--- + +# RestClient.Net 入门指南 + +RestClient.Net 是一个基于函数式编程模式构建的 C# 类型安全 REST 客户端。 + +## 安装 + +```bash +dotnet add package RestClient.Net +``` + +## 基本 GET 请求 + +```csharp +using System.Text.Json; +using RestClient.Net; +using Urls; + +// 定义模型 +internal sealed record Post(int UserId, int Id, string Title, string Body); +internal sealed record ErrorResponse(string Message); + +// 创建 HttpClient +using var httpClient = new HttpClient(); + +// 发起 GET 调用 +var result = await httpClient + .GetAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializePost, + deserializeError: DeserializeError + ); + +// 模式匹配 - 必须处理所有情况 +var output = result switch +{ + OkPost(var post) => $"成功: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"错误 {status}", + ErrorPost(ExceptionErrorPost(var ex)) => $"异常: {ex.Message}", +}; +``` + +## 类型别名 + +添加到 `GlobalUsings.cs`: + +```csharp +global using OkPost = Outcome.Result>.Ok>; +global using ErrorPost = Outcome.Result>.Error>; +global using ResponseErrorPost = Outcome.HttpError.ErrorResponseError; +global using ExceptionErrorPost = Outcome.HttpError.ExceptionError; +``` diff --git a/Website/src/zh/docs/installation.md b/Website/src/zh/docs/installation.md new file mode 100644 index 00000000..e334d26f --- /dev/null +++ b/Website/src/zh/docs/installation.md @@ -0,0 +1,181 @@ +--- +layout: layouts/docs.njk +title: 安装指南 +description: 安装 RestClient.Net 及相关包,实现 C# 中类型安全的 REST 调用。包含核心库、OpenAPI 生成器、MCP 服务器和穷尽性分析器的 NuGet 包。 +keywords: RestClient.Net 安装, NuGet 包, dotnet add package, C# REST 客户端设置 +lang: zh +permalink: /zh/docs/installation/ +eleventyNavigation: + key: 安装指南 + order: 2 +faq: + - question: 如何安装 RestClient.Net? + answer: 在项目目录中运行 'dotnet add package RestClient.Net' 即可从 NuGet 安装核心库。 + - question: RestClient.Net 支持哪些 .NET 版本? + answer: RestClient.Net 需要 .NET 8.0 或更高版本,以利用最新的 C# 模式匹配语言特性。 + - question: 需要单独安装 Exhaustion 分析器吗? + answer: Exhaustion 分析器在安装 RestClient.Net 时会自动包含。您也可以通过 'dotnet add package Exhaustion' 单独安装。 +--- + + + +# 安装指南 + +RestClient.Net 以 NuGet 包形式分发。本指南涵盖所有可用包的安装方法。 + +## 前置条件 + +- **.NET 8.0** 或更高版本 +- 代码编辑器(推荐使用 Visual Studio、VS Code 或 Rider) +- 对 C# 和 `HttpClient` 有基本了解 + +## 核心包 + +主包提供带有 Result 类型的 `HttpClient` 扩展方法: + +```bash +dotnet add package RestClient.Net +``` + +自动包含以下内容: +- `HttpClient` 扩展方法(`GetAsync`、`PostAsync` 等) +- `Result>` 类型 +- `AbsoluteUrl` 类型及扩展 +- **Exhaustion 分析器**,用于编译时穷尽性检查 + +## 包引用(csproj) + +或者直接添加到 `.csproj` 文件: + +```xml + + + +``` + +## 附加包 + +### OpenAPI 生成器 + +从 OpenAPI 3.x 规范生成类型安全的客户端: + +```bash +dotnet add package RestClient.Net.OpenApiGenerator +``` + +### MCP 服务器生成器 + +生成用于 Claude Code 集成的模型上下文协议服务器: + +```bash +dotnet add package RestClient.Net.McpGenerator +``` + +### 独立 Exhaustion 分析器 + +如果只需要穷尽性分析器而不需要 REST 客户端: + +```bash +dotnet add package Exhaustion +``` + +## 快速设置 + +安装后,在项目根目录创建 `GlobalUsings.cs` 文件: + +```csharp +// GlobalUsings.cs +global using System.Net.Http.Json; +global using System.Text.Json; +global using RestClient.Net; +global using Urls; +``` + +为每个使用的模型类型添加类型别名,以实现更简洁的模式匹配: + +```csharp +// User 模型与 ApiError 的示例 +global using OkUser = Outcome.Result> + .Ok>; + +global using ErrorUser = Outcome.Result> + .Error>; + +global using ResponseErrorUser = Outcome.HttpError.ErrorResponseError; + +global using ExceptionErrorUser = Outcome.HttpError.ExceptionError; +``` + +## 验证安装 + +创建一个简单的测试来验证一切正常工作: + +```csharp +using System.Text.Json; +using RestClient.Net; +using Urls; + +// 定义模型 +record Post(int UserId, int Id, string Title, string Body); +record ErrorResponse(string Message); + +// 创建 HttpClient +using var httpClient = new HttpClient(); + +// 发起测试调用 +var result = await httpClient.GetAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) ?? throw new Exception("空响应"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) ?? new ErrorResponse("未知错误") +); + +Console.WriteLine(result switch +{ + Outcome.Result>.Ok(var post) => + $"成功: {post.Title}", + Outcome.Result>.Error(var error) => + $"错误: {error}", +}); +``` + +## IDE 支持 + +### Visual Studio + +Exhaustion 分析器开箱即用。对于非穷尽的 switch 表达式,您将看到编译错误。 + +### VS Code + +安装 C# 扩展(ms-dotnettools.csharp)以获得完整的分析器支持。 + +### Rider + +JetBrains Rider 完全支持 Roslyn 分析器,包括 Exhaustion。 + +## 下一步 + +- [基本用法](/zh/docs/basic-usage/) - 学习基础知识 +- [错误处理](/zh/docs/error-handling/) - 掌握 Result 类型 +- [OpenAPI 生成器](/zh/docs/openapi/) - 从规范生成客户端 diff --git a/Website/src/zh/docs/mcp.md b/Website/src/zh/docs/mcp.md new file mode 100644 index 00000000..48db4bde --- /dev/null +++ b/Website/src/zh/docs/mcp.md @@ -0,0 +1,47 @@ +--- +layout: layouts/docs.njk +title: MCP 服务器 +lang: zh +permalink: /zh/docs/mcp/ +eleventyNavigation: + key: MCP 服务器 + order: 3 +--- + +# MCP 服务器生成 + +从 OpenAPI 规范为 Claude Code 生成模型上下文协议服务器。 + +## 首先生成客户端 + +```bash +dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \ + -u api.yaml -o Generated -n YourApi.Generated +``` + +## 生成 MCP 工具 + +```bash +dotnet run --project RestClient.Net.McpGenerator.Cli -- \ + --openapi-url api.yaml \ + --output-file Generated/McpTools.g.cs \ + --namespace YourApi.Mcp \ + --server-name YourApi \ + --ext-namespace YourApi.Generated \ + --tags "Search,Resources" +``` + +## Claude Code 集成 + +添加到你的 Claude Code 配置: + +```json +{ + "mcpServers": { + "yourapi": { + "command": "dotnet", + "args": ["run", "--project", "YourApi.McpServer"] + } + } +} +``` diff --git a/Website/src/zh/docs/openapi.md b/Website/src/zh/docs/openapi.md new file mode 100644 index 00000000..3176d668 --- /dev/null +++ b/Website/src/zh/docs/openapi.md @@ -0,0 +1,49 @@ +--- +layout: layouts/docs.njk +title: OpenAPI 生成器 +lang: zh +permalink: /zh/docs/openapi/ +eleventyNavigation: + key: OpenAPI 生成器 + order: 2 +--- + +# OpenAPI 客户端生成 + +从 OpenAPI 3.x 规范生成类型安全的 C# 客户端。 + +## 安装 + +```bash +dotnet add package RestClient.Net.OpenApiGenerator +``` + +## 使用方法 + +```bash +dotnet run --project RestClient.Net.OpenApiGenerator.Cli -- \ + -u api.yaml \ + -o Generated \ + -n YourApi.Generated +``` + +## 生成的代码 + +```csharp +using YourApi.Generated; + +var httpClient = factory.CreateClient(); + +var user = await httpClient.GetUserById("123", ct); +var created = await httpClient.CreateUser(newUser, ct); + +switch (user) +{ + case OkUser(var success): + Console.WriteLine($"用户: {success.Name}"); + break; + case ErrorUser(var error): + Console.WriteLine($"错误: {error.StatusCode}"); + break; +} +``` diff --git a/Website/src/zh/examples/index.njk b/Website/src/zh/examples/index.njk new file mode 100644 index 00000000..2d82bded --- /dev/null +++ b/Website/src/zh/examples/index.njk @@ -0,0 +1,281 @@ +--- +layout: layouts/base.njk +title: 代码示例 +lang: zh +permalink: /zh/examples/ +--- +
+

代码示例

+

常见场景的完整可用示例

+ +
+ +
+

基本 GET 请求

+

使用类型安全的错误处理从 REST API 获取数据。

+ {% highlight "csharp" %}using System.Net.Http.Json; +using RestClient.Net; +using Urls; + +// 定义模型 +record Post(int UserId, int Id, string Title, string Body); +record ApiError(string Message); + +// 类型别名,用于简洁的模式匹配 +using OkPost = Outcome.Result> + .Ok>; +using ErrorPost = Outcome.Result> + .Error>; +using ResponseErrorPost = Outcome.HttpError.ErrorResponseError; +using ExceptionErrorPost = Outcome.HttpError.ExceptionError; + +// 发起请求 +using var httpClient = new HttpClient(); +var result = await httpClient.GetAsync( + url: "https://jsonplaceholder.typicode.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) ?? throw new Exception("Null"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) ?? new ApiError("Unknown") +); + +// 处理所有情况 +var message = result switch +{ + OkPost(var post) => $"标题: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"错误 {status}: {err.Message}", + ErrorPost(ExceptionErrorPost(var ex)) => $"异常: {ex.Message}", +}; + +Console.WriteLine(message);{% endhighlight %} +
+ +
+

带请求体的 POST 请求

+

使用 JSON 请求体创建新资源。

+ {% highlight "csharp" %}record CreatePostRequest(string Title, string Body, int UserId); + +var newPost = new CreatePostRequest("我的标题", "我的内容", 1); + +var result = await httpClient.PostAsync( + url: "https://jsonplaceholder.typicode.com/posts".ToAbsoluteUrl(), + body: newPost, + serializeRequest: body => JsonContent.Create(body), + deserializeSuccess: async (content, ct) => + await content.ReadFromJsonAsync(ct) ?? throw new Exception("Null"), + deserializeError: async (content, ct) => + await content.ReadFromJsonAsync(ct) ?? new ApiError("Unknown") +); + +var message = result switch +{ + OkPost(var post) => $"创建的文章 ID: {post.Id}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"失败: {status}", + ErrorPost(ExceptionErrorPost(var ex)) => $"异常: {ex.Message}", +};{% endhighlight %} +
+ +
+

使用 IHttpClientFactory

+

在 ASP.NET Core 应用程序中正确使用 HttpClient。

+ {% highlight "csharp" %}// Program.cs - 注册客户端 +builder.Services.AddHttpClient("api", client => +{ + client.BaseAddress = new Uri("https://api.example.com"); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + client.Timeout = TimeSpan.FromSeconds(30); +}); + +// UserService.cs - 使用客户端 +public class UserService(IHttpClientFactory httpClientFactory) +{ + public async Task>> GetUserAsync( + string userId, + CancellationToken ct = default) + { + var client = httpClientFactory.CreateClient("api"); + + return await client.GetAsync( + url: $"/users/{userId}".ToAbsoluteUrl(), + deserializeSuccess: Deserializers.Json, + deserializeError: Deserializers.Error, + cancellationToken: ct + ); + } +}{% endhighlight %} +
+ +
+

使用 Polly 的重试策略

+

为瞬态故障添加自动重试。

+ {% highlight "csharp" %}// Program.cs +builder.Services.AddHttpClient("api") + .AddStandardResilienceHandler(options => + { + options.Retry.MaxRetryAttempts = 3; + options.Retry.Delay = TimeSpan.FromMilliseconds(500); + options.Retry.UseJitter = true; + options.Retry.ShouldHandle = args => ValueTask.FromResult( + args.Outcome.Exception is not null || + args.Outcome.Result?.StatusCode >= HttpStatusCode.InternalServerError + ); + });{% endhighlight %} +
+ +
+

认证处理器

+

自动为请求添加认证令牌。

+ {% highlight "csharp" %}public class AuthenticationHandler(ITokenService tokenService) : DelegatingHandler +{ + protected override async Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + var token = await tokenService.GetAccessTokenAsync(cancellationToken); + + request.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", token); + + var response = await base.SendAsync(request, cancellationToken); + + // 如果令牌过期则刷新 + if (response.StatusCode == HttpStatusCode.Unauthorized) + { + token = await tokenService.RefreshTokenAsync(cancellationToken); + request.Headers.Authorization = + new AuthenticationHeaderValue("Bearer", token); + response = await base.SendAsync(request, cancellationToken); + } + + return response; + } +} + +// Program.cs +builder.Services.AddTransient(); +builder.Services.AddHttpClient("api") + .AddHttpMessageHandler();{% endhighlight %} +
+ +
+

状态码特定处理

+

针对不同的 HTTP 状态码进行不同处理。

+ {% highlight "csharp" %}var result = await httpClient.GetUserAsync(userId); + +var message = result switch +{ + OkUser(var user) => $"找到: {user.Name}", + + // 未找到 - 用户不存在 + ErrorUser(ResponseErrorUser(_, HttpStatusCode.NotFound, _)) => + "用户未找到。请检查 ID。", + + // 未授权 - 需要登录 + ErrorUser(ResponseErrorUser(_, HttpStatusCode.Unauthorized, _)) => + "请登录以查看此用户。", + + // 禁止访问 - 无权限 + ErrorUser(ResponseErrorUser(_, HttpStatusCode.Forbidden, _)) => + "您没有权限查看此用户。", + + // 请求过多 + ErrorUser(ResponseErrorUser(_, HttpStatusCode.TooManyRequests, var response)) => + { + var retryAfter = response.Headers.RetryAfter?.Delta; + return $"请求过多。请在 {retryAfter?.TotalSeconds ?? 60} 秒后重试。"; + }, + + // 服务器错误 + ErrorUser(ResponseErrorUser(var err, var status, _)) when (int)status >= 500 => + "服务器出现问题。请稍后重试。", + + // 其他 API 错误 + ErrorUser(ResponseErrorUser(var err, var status, _)) => + $"API 错误 {(int)status}: {err.Message}", + + // 网络/超时错误 + ErrorUser(ExceptionErrorUser(TaskCanceledException ex)) + when ex.CancellationToken.IsCancellationRequested => + "请求已取消。", + + ErrorUser(ExceptionErrorUser(TaskCanceledException)) => + "请求超时。请重试。", + + ErrorUser(ExceptionErrorUser(HttpRequestException)) => + "网络错误。请检查您的连接。", + + ErrorUser(ExceptionErrorUser(var ex)) => + $"意外错误: {ex.Message}", +};{% endhighlight %} +
+ +
+

链式多个请求

+

链接依赖的 API 调用并正确传播错误。

+ {% highlight "csharp" %}// 获取用户,然后获取他们的订单,再获取订单详情 +public async Task>> GetUserOrderDetailsAsync( + string userId, + CancellationToken ct) +{ + // 首先,获取用户 + var userResult = await httpClient.GetUserAsync(userId, ct); + + return await userResult switch + { + OkUser(var user) => await GetOrdersForUserAsync(user, ct), + ErrorUser(var error) => new Result> + .Error(error), + }; +} + +private async Task>> GetOrdersForUserAsync( + User user, + CancellationToken ct) +{ + var ordersResult = await httpClient.GetOrdersAsync(user.Id, ct); + + return ordersResult switch + { + OkOrders(var orders) => new Result> + .Ok(new OrderDetails(user, orders)), + ErrorOrders(var error) => new Result> + .Error(error), + }; +}{% endhighlight %} +
+ +
+

并行请求

+

并行发起多个独立请求。

+ {% highlight "csharp" %}public async Task GetDashboardAsync(string userId, CancellationToken ct) +{ + // 并行启动所有请求 + var userTask = httpClient.GetUserAsync(userId, ct); + var ordersTask = httpClient.GetOrdersAsync(userId, ct); + var notificationsTask = httpClient.GetNotificationsAsync(userId, ct); + + // 等待所有请求完成 + await Task.WhenAll(userTask, ordersTask, notificationsTask); + + var userResult = await userTask; + var ordersResult = await ordersTask; + var notificationsResult = await notificationsTask; + + // 合并结果 + return (userResult, ordersResult, notificationsResult) switch + { + (OkUser(var user), OkOrders(var orders), OkNotifications(var notifications)) => + new Dashboard(user, orders, notifications), + + (ErrorUser(var e), _, _) => + throw new Exception($"加载用户失败: {e}"), + (_, ErrorOrders(var e), _) => + throw new Exception($"加载订单失败: {e}"), + (_, _, ErrorNotifications(var e)) => + throw new Exception($"加载通知失败: {e}"), + }; +}{% endhighlight %} +
+ +
+
diff --git a/Website/src/zh/index.njk b/Website/src/zh/index.njk new file mode 100644 index 00000000..1690be09 --- /dev/null +++ b/Website/src/zh/index.njk @@ -0,0 +1,112 @@ +--- +layout: layouts/base.njk +title: RestClient.Net - C# 类型安全 REST 客户端 +lang: zh +permalink: /zh/ +--- + +
+
+

RestClient.Net

+

C# 中最安全的 REST 调用方式。基于函数式编程、类型安全和现代 .NET 模式从头构建。

+ +
+
+ +
+
+
+

Result 类型

+

返回 Result<TSuccess, HttpError<TError>>,使用封闭层次类型实现编译时安全。不再猜测异常处理。

+
+ +
+

零异常

+

不抛出异常,实现可预测的错误处理。每种可能的结果都在类型系统中表示。

+
+ +
+

穷尽性检查

+

使用 Exhaustion 分析器保证编译时完整性。如果你没有处理所有情况,代码将无法编译。

+
+ +
+

HttpClient 扩展

+

IHttpClientFactory.CreateClient() 配合使用,实现正确的连接池和 DNS 行为处理。

+
+ +
+

OpenAPI 生成器

+

从 OpenAPI 3.x 规范生成类型安全的 C# 客户端。自动生成模型和 Result 类型别名。

+
+ +
+

MCP 服务器生成器

+

从 OpenAPI 规范为 Claude Code 生成模型上下文协议服务器。AI 就绪的 API 集成。

+
+
+
+ +
+

快速安装

+ {% highlight "bash" %}dotnet add package RestClient.Net{% endhighlight %} + +

基本用法

+ {% highlight "csharp" %}using RestClient.Net; + +// 发起 GET 请求 +var result = await httpClient + .GetAsync( + url: "https://api.example.com/posts/1".ToAbsoluteUrl(), + deserializeSuccess: DeserializePost, + deserializeError: DeserializeError + ); + +// 模式匹配结果 - 必须处理所有情况 +var output = result switch +{ + OkPost(var post) => $"成功: {post.Title}", + ErrorPost(ResponseErrorPost(var err, var status, _)) => $"错误 {status}", + ErrorPost(ExceptionErrorPost(var ex)) => $"异常: {ex.Message}", +};{% endhighlight %} +
+ +
+

为什么使用可辨识联合?

+

+ C# 尚未正式支持可辨识联合,但 RestClient.Net 今天就将这种强大的模式带入你的代码。配合 Exhaustion 分析器,遗漏任何情况都会导致编译失败。 +

+ +
+
+

不使用 Exhaustion

+ {% highlight "csharp" %}// 危险 - 编译通过但可能抛出异常 +var output = result switch +{ + OkPost(var post) => "成功", + ErrorPost(ResponseErrorPost(...)) => "错误", + // 遗漏了 ExceptionErrorPost! + // 运行时崩溃等着发生 +};{% endhighlight %} +
+
+

使用 Exhaustion

+ {% highlight "csharp" %}// 安全 - 编译器报错! +// error EXHAUSTION001: Switch 不完整 +// 遗漏: ExceptionErrorPost +// 构建失败直到处理所有情况{% endhighlight %} +
+
+
+ +
+

准备好开始了吗?

+ +
diff --git a/Website/tests/api.test.js b/Website/tests/api.test.js new file mode 100644 index 00000000..6c05b379 --- /dev/null +++ b/Website/tests/api.test.js @@ -0,0 +1,72 @@ +/** + * API Reference Tests + */ +import { test, expect } from '@playwright/test'; + +test.describe('API Reference', () => { + test('API index loads', async ({ page }) => { + const response = await page.goto('/api/'); + expect(response?.status()).toBe(200); + }); + + test('API page has title', async ({ page }) => { + await page.goto('/api/'); + const h1 = page.locator('h1'); + await expect(h1).toContainText('API'); + }); + + test('API page has package links', async ({ page }) => { + await page.goto('/api/'); + const links = page.locator('.feature-card, a[href*="docs"]'); + const count = await links.count(); + expect(count).toBeGreaterThan(0); + }); +}); + +test.describe('API Documentation Pages', () => { + test('HttpClient Extensions page loads', async ({ page }) => { + const response = await page.goto('/api/httpclient-extensions/'); + expect(response?.status()).toBe(200); + }); + + test('HttpClient Extensions has method docs', async ({ page }) => { + await page.goto('/api/httpclient-extensions/'); + const content = await page.content(); + expect(content).toContain('GetAsync'); + expect(content).toContain('PostAsync'); + }); + + test('Result Types page loads', async ({ page }) => { + const response = await page.goto('/api/result-types/'); + expect(response?.status()).toBe(200); + }); + + test('Result Types has type documentation', async ({ page }) => { + await page.goto('/api/result-types/'); + const content = await page.content(); + expect(content).toContain('HttpError'); + expect(content).toContain('pattern matching'); + }); + + test('OpenAPI Generator page loads', async ({ page }) => { + const response = await page.goto('/api/openapi-generator/'); + expect(response?.status()).toBe(200); + }); + + test('OpenAPI Generator has CLI docs', async ({ page }) => { + await page.goto('/api/openapi-generator/'); + const content = await page.content(); + expect(content).toContain('OpenApiGenerator'); + }); + + test('MCP Generator page loads', async ({ page }) => { + const response = await page.goto('/api/mcp-generator/'); + expect(response?.status()).toBe(200); + }); + + test('MCP Generator has MCP protocol docs', async ({ page }) => { + await page.goto('/api/mcp-generator/'); + const content = await page.content(); + expect(content).toContain('Model Context Protocol'); + }); +}); diff --git a/Website/tests/blog.test.js b/Website/tests/blog.test.js new file mode 100644 index 00000000..8d843cc3 --- /dev/null +++ b/Website/tests/blog.test.js @@ -0,0 +1,35 @@ +/** + * Blog Tests + */ +import { test, expect } from '@playwright/test'; + +test.describe('Blog', () => { + test('blog index loads', async ({ page }) => { + const response = await page.goto('/blog/'); + expect(response?.status()).toBe(200); + }); + + test('blog has title', async ({ page }) => { + await page.goto('/blog/'); + const h1 = page.locator('h1'); + await expect(h1).toContainText('Blog'); + }); + + test('blog has post list', async ({ page }) => { + await page.goto('/blog/'); + const posts = page.locator('.post-list li, ul li a'); + const count = await posts.count(); + expect(count).toBeGreaterThan(0); + }); + + test('blog post page loads', async ({ page }) => { + const response = await page.goto('/blog/introducing-restclient/'); + expect(response?.status()).toBe(200); + }); + + test('blog post has back link', async ({ page }) => { + await page.goto('/blog/introducing-restclient/'); + const backLink = page.locator('a[href="/blog/"]'); + await expect(backLink.first()).toBeVisible(); + }); +}); diff --git a/Website/tests/chinese-i18n.test.js b/Website/tests/chinese-i18n.test.js new file mode 100644 index 00000000..2583e7a5 --- /dev/null +++ b/Website/tests/chinese-i18n.test.js @@ -0,0 +1,81 @@ +/** + * Chinese i18n Tests + */ +import { test, expect } from '@playwright/test'; + +test.describe('Chinese Homepage', () => { + test('/zh/ loads', async ({ page }) => { + const response = await page.goto('/zh/'); + expect(response?.status()).toBe(200); + }); + + test('/zh/ has lang="zh" attribute', async ({ page }) => { + await page.goto('/zh/'); + await expect(page.locator('html')).toHaveAttribute('lang', 'zh'); + }); + + test('/zh/ has Chinese content', async ({ page }) => { + await page.goto('/zh/'); + const content = await page.content(); + expect(content).toMatch(/[\u4e00-\u9fff]/); + }); +}); + +test.describe('Chinese Docs', () => { + test('/zh/docs/ loads', async ({ page }) => { + const response = await page.goto('/zh/docs/'); + expect(response?.status()).toBe(200); + }); + + test('/zh/docs/openapi/ loads', async ({ page }) => { + const response = await page.goto('/zh/docs/openapi/'); + expect(response?.status()).toBe(200); + }); + + test('/zh/docs/mcp/ loads', async ({ page }) => { + const response = await page.goto('/zh/docs/mcp/'); + expect(response?.status()).toBe(200); + }); + + test('/zh/docs/exhaustion/ loads', async ({ page }) => { + const response = await page.goto('/zh/docs/exhaustion/'); + expect(response?.status()).toBe(200); + }); +}); + +test.describe('Chinese Blog', () => { + test('/zh/blog/ loads', async ({ page }) => { + const response = await page.goto('/zh/blog/'); + expect(response?.status()).toBe(200); + }); + + test('/zh/blog/ has Chinese title', async ({ page }) => { + await page.goto('/zh/blog/'); + const h1 = page.locator('h1'); + const text = await h1.textContent(); + expect(text).toContain('博客'); + }); +}); + +test.describe('Chinese API', () => { + test('/zh/api/ loads', async ({ page }) => { + const response = await page.goto('/zh/api/'); + expect(response?.status()).toBe(200); + }); +}); + +test.describe('Language Selector', () => { + test('language selector exists', async ({ page }) => { + await page.goto('/'); + const langBtn = page.locator('.language-btn, .language-switcher button, [aria-label*="language"]'); + await expect(langBtn.first()).toBeVisible(); + }); + + test('language dropdown has Chinese option', async ({ page }) => { + await page.goto('/'); + const langBtn = page.locator('.language-btn, .language-switcher button').first(); + await langBtn.click(); + const zhOption = page.locator('.language-dropdown a[lang="zh"], .language-dropdown a:has-text("中文")'); + await expect(zhOption.first()).toBeVisible(); + }); +}); diff --git a/Website/tests/docs.test.js b/Website/tests/docs.test.js new file mode 100644 index 00000000..54fc67e2 --- /dev/null +++ b/Website/tests/docs.test.js @@ -0,0 +1,38 @@ +/** + * Documentation Tests + */ +import { test, expect } from '@playwright/test'; + +test.describe('Documentation', () => { + test('docs index loads', async ({ page }) => { + const response = await page.goto('/docs/'); + expect(response?.status()).toBe(200); + }); + + test('docs has title', async ({ page }) => { + await page.goto('/docs/'); + const h1 = page.locator('h1'); + await expect(h1).toContainText(/Getting Started|RestClient/); + }); + + test('docs has code examples', async ({ page }) => { + await page.goto('/docs/'); + const code = page.locator('pre code'); + await expect(code.first()).toBeVisible(); + }); + + test('openapi docs loads', async ({ page }) => { + const response = await page.goto('/docs/openapi/'); + expect(response?.status()).toBe(200); + }); + + test('mcp docs loads', async ({ page }) => { + const response = await page.goto('/docs/mcp/'); + expect(response?.status()).toBe(200); + }); + + test('exhaustion docs loads', async ({ page }) => { + const response = await page.goto('/docs/exhaustion/'); + expect(response?.status()).toBe(200); + }); +}); diff --git a/Website/tests/homepage.test.js b/Website/tests/homepage.test.js new file mode 100644 index 00000000..3c0f1783 --- /dev/null +++ b/Website/tests/homepage.test.js @@ -0,0 +1,47 @@ +/** + * Homepage Tests + */ +import { test, expect } from '@playwright/test'; + +test.describe('Homepage', () => { + test('homepage loads', async ({ page }) => { + const response = await page.goto('/'); + expect(response?.status()).toBe(200); + }); + + test('has correct title', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveTitle(/RestClient\.Net/); + }); + + test('has hero section', async ({ page }) => { + await page.goto('/'); + const hero = page.locator('.hero'); + await expect(hero).toBeVisible(); + }); + + test('has feature cards', async ({ page }) => { + await page.goto('/'); + const features = page.locator('.feature-card'); + const count = await features.count(); + expect(count).toBeGreaterThan(0); + }); + + test('has navigation', async ({ page }) => { + await page.goto('/'); + const nav = page.locator('nav, .nav'); + await expect(nav.first()).toBeVisible(); + }); + + test('has docs link in nav', async ({ page }) => { + await page.goto('/'); + const docsLink = page.locator('a[href="/docs/"], a[href*="docs"]').first(); + await expect(docsLink).toBeVisible(); + }); + + test('has code example', async ({ page }) => { + await page.goto('/'); + const code = page.locator('pre code'); + await expect(code.first()).toBeVisible(); + }); +}); diff --git a/Website/tests/seo.test.js b/Website/tests/seo.test.js new file mode 100644 index 00000000..9ada13cb --- /dev/null +++ b/Website/tests/seo.test.js @@ -0,0 +1,70 @@ +/** + * SEO Tests + */ +import { test, expect } from '@playwright/test'; + +test.describe('SEO Files', () => { + test('robots.txt exists', async ({ page }) => { + const response = await page.goto('/robots.txt'); + expect(response?.status()).toBe(200); + const content = await page.content(); + expect(content).toContain('User-agent'); + }); + + test('sitemap.xml exists', async ({ page }) => { + const response = await page.goto('/sitemap.xml'); + expect(response?.status()).toBe(200); + }); + + test('feed.xml exists', async ({ page }) => { + const response = await page.goto('/feed.xml'); + expect(response?.status()).toBe(200); + }); + + test('llms.txt exists', async ({ page }) => { + const response = await page.goto('/llms.txt'); + expect(response?.status()).toBe(200); + }); +}); + +test.describe('Meta Tags', () => { + test('homepage has meta description', async ({ page }) => { + await page.goto('/'); + const meta = page.locator('meta[name="description"]'); + await expect(meta).toHaveAttribute('content', /.+/); + }); + + test('homepage has og:title', async ({ page }) => { + await page.goto('/'); + const meta = page.locator('meta[property="og:title"]'); + await expect(meta).toHaveAttribute('content', /.+/); + }); + + test('homepage has twitter:card', async ({ page }) => { + await page.goto('/'); + const meta = page.locator('meta[name="twitter:card"]'); + await expect(meta).toHaveAttribute('content', /.+/); + }); + + test('homepage has canonical URL', async ({ page }) => { + await page.goto('/'); + const link = page.locator('link[rel="canonical"]'); + await expect(link).toHaveAttribute('href', /.+/); + }); + + test('homepage has hreflang tags', async ({ page }) => { + await page.goto('/'); + const zhHreflang = page.locator('link[hreflang="zh"]'); + await expect(zhHreflang).toHaveAttribute('href', /\/zh\//); + }); +}); + +test.describe('JSON-LD', () => { + test('homepage has JSON-LD structured data', async ({ page }) => { + await page.goto('/'); + const jsonLd = page.locator('script[type="application/ld+json"]'); + // Scripts are hidden elements, check count instead of visibility + const count = await jsonLd.count(); + expect(count).toBeGreaterThan(0); + }); +}); diff --git a/Website/tests/syntax-highlighting.test.js b/Website/tests/syntax-highlighting.test.js new file mode 100644 index 00000000..ce963c60 --- /dev/null +++ b/Website/tests/syntax-highlighting.test.js @@ -0,0 +1,142 @@ +/** + * Syntax Highlighting Tests + * Verifies that ALL pages with code blocks have proper Prism syntax highlighting + * NO SKIPPING - FAIL HARD if anything is wrong! + */ +import { test, expect } from '@playwright/test'; + +// All pages that MUST contain code blocks with syntax highlighting +// If any page doesn't have code or highlighting, TEST FAILS! +const pagesWithCode = [ + // English pages + { url: '/', name: 'Homepage', minTokens: 20 }, + { url: '/docs/', name: 'Docs index', minTokens: 10 }, + { url: '/docs/basic-usage/', name: 'Basic Usage', minTokens: 20 }, + { url: '/docs/error-handling/', name: 'Error Handling', minTokens: 20 }, + { url: '/docs/advanced-usage/', name: 'Advanced Usage', minTokens: 20 }, + { url: '/docs/exhaustion/', name: 'Exhaustion', minTokens: 10 }, + { url: '/docs/openapi/', name: 'OpenAPI', minTokens: 10 }, + { url: '/docs/mcp/', name: 'MCP', minTokens: 10 }, + { url: '/examples/', name: 'Examples', minTokens: 100 }, + { url: '/blog/introducing-restclient/', name: 'Blog post', minTokens: 10 }, + + // Chinese pages + { url: '/zh/', name: 'Chinese Homepage', minTokens: 20 }, + { url: '/zh/docs/', name: 'Chinese Docs index', minTokens: 10 }, + { url: '/zh/docs/basic-usage/', name: 'Chinese Basic Usage', minTokens: 20 }, + { url: '/zh/docs/error-handling/', name: 'Chinese Error Handling', minTokens: 20 }, + { url: '/zh/examples/', name: 'Chinese Examples', minTokens: 100 }, +]; + +test.describe('Syntax Highlighting - All Pages MUST Have Tokens', () => { + for (const { url, name, minTokens } of pagesWithCode) { + test(`${name} (${url}) MUST have at least ${minTokens} syntax tokens`, async ({ page }) => { + await page.goto(url); + + // Page MUST have code blocks - NO EXCEPTIONS + const codeBlocks = page.locator('pre code'); + const codeBlockCount = await codeBlocks.count(); + expect(codeBlockCount, `${name} MUST have code blocks but has ZERO!`).toBeGreaterThan(0); + + // Code blocks MUST have syntax highlighting tokens - NO EXCEPTIONS + const tokens = page.locator('pre code .token'); + const tokenCount = await tokens.count(); + expect(tokenCount, `${name} has ${codeBlockCount} code blocks but ZERO syntax tokens! BROKEN!`).toBeGreaterThanOrEqual(minTokens); + }); + } +}); + +test.describe('Syntax Highlighting - Token Types MUST Exist', () => { + test('Homepage MUST have keyword tokens', async ({ page }) => { + await page.goto('/'); + const keywords = page.locator('.token.keyword'); + const count = await keywords.count(); + expect(count, 'Homepage MUST have keyword tokens for C# code - BROKEN!').toBeGreaterThan(0); + }); + + test('Homepage MUST have string tokens', async ({ page }) => { + await page.goto('/'); + const strings = page.locator('.token.string'); + const count = await strings.count(); + expect(count, 'Homepage MUST have string tokens for C# code - BROKEN!').toBeGreaterThan(0); + }); + + test('Examples page MUST have keyword tokens', async ({ page }) => { + await page.goto('/examples/'); + const keywords = await page.locator('.token.keyword').count(); + expect(keywords, 'Examples MUST have keyword tokens - BROKEN!').toBeGreaterThan(10); + }); + + test('Examples page MUST have string tokens', async ({ page }) => { + await page.goto('/examples/'); + const strings = await page.locator('.token.string').count(); + expect(strings, 'Examples MUST have string tokens - BROKEN!').toBeGreaterThan(10); + }); + + test('Examples page MUST have punctuation tokens', async ({ page }) => { + await page.goto('/examples/'); + const punctuation = await page.locator('.token.punctuation').count(); + expect(punctuation, 'Examples MUST have punctuation tokens - BROKEN!').toBeGreaterThan(50); + }); + + test('Chinese Examples MUST have keyword tokens', async ({ page }) => { + await page.goto('/zh/examples/'); + const keywords = await page.locator('.token.keyword').count(); + expect(keywords, 'Chinese Examples MUST have keyword tokens - BROKEN!').toBeGreaterThan(10); + }); + + test('Chinese Examples MUST have string tokens', async ({ page }) => { + await page.goto('/zh/examples/'); + const strings = await page.locator('.token.string').count(); + expect(strings, 'Chinese Examples MUST have string tokens - BROKEN!').toBeGreaterThan(10); + }); +}); + +// API method detail pages - these have actual code examples +test.describe('Syntax Highlighting - API Method Detail Pages', () => { + const apiMethodPages = [ + { url: '/api/getasync/', name: 'GetAsync' }, + { url: '/api/postasync/', name: 'PostAsync' }, + { url: '/api/putasync/', name: 'PutAsync' }, + { url: '/api/deleteasync/', name: 'DeleteAsync' }, + { url: '/api/patchasync/', name: 'PatchAsync' }, + ]; + + for (const { url, name } of apiMethodPages) { + test(`${name} method page MUST have syntax highlighting`, async ({ page }) => { + await page.goto(url); + const codeBlocks = await page.locator('pre code').count(); + expect(codeBlocks, `${name} MUST have code blocks - BROKEN!`).toBeGreaterThan(0); + const tokens = await page.locator('pre code .token').count(); + expect(tokens, `${name} MUST have syntax tokens - BROKEN!`).toBeGreaterThan(0); + }); + } +}); + +test.describe('Syntax Highlighting - Visual MUST Be Correct', () => { + test('tokens MUST have color applied (not plain black text)', async ({ page }) => { + await page.goto('/examples/'); + + const keyword = page.locator('.token.keyword').first(); + await expect(keyword, 'No keyword token found - BROKEN!').toBeVisible(); + + const color = await keyword.evaluate(el => getComputedStyle(el).color); + expect(color, 'Keyword token has no color - CSS BROKEN!').not.toBe('rgb(0, 0, 0)'); + }); + + test('Homepage code blocks MUST have language class', async ({ page }) => { + await page.goto('/'); + + const codeWithLang = page.locator('pre code[class*="language-"]'); + const count = await codeWithLang.count(); + expect(count, 'Homepage code blocks MUST have language-* class - BROKEN!').toBeGreaterThan(0); + }); + + test('Examples code blocks MUST have language class', async ({ page }) => { + await page.goto('/examples/'); + + const codeWithLang = page.locator('pre code[class*="language-"]'); + const count = await codeWithLang.count(); + expect(count, 'Examples code blocks MUST have language-* class - BROKEN!').toBeGreaterThan(0); + }); +}); diff --git a/Website/tests/visual-qa.test.js b/Website/tests/visual-qa.test.js new file mode 100644 index 00000000..545a6ede --- /dev/null +++ b/Website/tests/visual-qa.test.js @@ -0,0 +1,179 @@ +import { test, expect } from '@playwright/test'; + +const baseUrl = 'http://localhost:8080'; + +test.describe('Visual QA Checks', () => { + test('homepage hero section renders correctly', async ({ page }) => { + await page.goto(baseUrl); + const hero = page.locator('.hero'); + await expect(hero).toBeVisible(); + await expect(page.locator('.hero-logo')).toBeVisible(); + await expect(page.locator('.hero-tagline')).toBeVisible(); + await expect(page.locator('.hero-actions')).toBeVisible(); + }); + + test('homepage feature cards have proper styling', async ({ page }) => { + await page.goto(baseUrl); + const cards = page.locator('.feature-card'); + await expect(cards.first()).toBeVisible(); + const count = await cards.count(); + expect(count).toBeGreaterThanOrEqual(6); + }); + + test('Why Discriminated Unions section renders', async ({ page }) => { + await page.goto(baseUrl); + const unionSection = page.locator('text=Why Discriminated Unions?'); + await expect(unionSection).toBeVisible(); + + // Check both code examples exist + const withoutExhaustion = page.locator('text=Without Exhaustion'); + const withExhaustion = page.locator('text=With Exhaustion'); + await expect(withoutExhaustion).toBeVisible(); + await expect(withExhaustion).toBeVisible(); + + // Check code blocks in this section exist + const codeBlocks = page.locator('.feature-card pre code'); + const codeCount = await codeBlocks.count(); + expect(codeCount).toBeGreaterThanOrEqual(2); + }); + + test('navigation links work', async ({ page }) => { + await page.goto(baseUrl); + + // Check docs link + await page.click('a[href="/docs/"]'); + await expect(page).toHaveURL(/\/docs\//); + + // Check API link + await page.click('a[href="/api/"]'); + await expect(page).toHaveURL(/\/api\//); + + // Check blog link + await page.click('a[href="/blog/"]'); + await expect(page).toHaveURL(/\/blog\//); + }); + + test('navigation active state is exclusive - only ONE nav item should be active per page', async ({ page }) => { + // Test home page - only Home should be active + await page.goto(baseUrl); + let activeLinks = page.locator('.nav-link.active'); + await expect(activeLinks).toHaveCount(1); + await expect(activeLinks.first()).toHaveText('Home'); + + // Test docs page - only Docs should be active + await page.goto(`${baseUrl}/docs/`); + activeLinks = page.locator('.nav-link.active'); + await expect(activeLinks).toHaveCount(1); + await expect(activeLinks.first()).toHaveText('Docs'); + + // Test API page - only API should be active + await page.goto(`${baseUrl}/api/`); + activeLinks = page.locator('.nav-link.active'); + await expect(activeLinks).toHaveCount(1); + await expect(activeLinks.first()).toHaveText('API'); + + // Test blog page - only Blog should be active + await page.goto(`${baseUrl}/blog/`); + activeLinks = page.locator('.nav-link.active'); + await expect(activeLinks).toHaveCount(1); + await expect(activeLinks.first()).toHaveText('Blog'); + + // Test examples page - only Examples should be active + await page.goto(`${baseUrl}/examples/`); + activeLinks = page.locator('.nav-link.active'); + await expect(activeLinks).toHaveCount(1); + await expect(activeLinks.first()).toHaveText('Examples'); + }); + + test('docs sidebar navigation works', async ({ page }) => { + await page.goto(`${baseUrl}/docs/`); + const sidebar = page.locator('.sidebar, .docs-sidebar'); + await expect(sidebar.first()).toBeVisible(); + }); + + test('code blocks have proper syntax highlighting colors', async ({ page }) => { + await page.goto(baseUrl); + const codeBlock = page.locator('pre code').first(); + await expect(codeBlock).toBeVisible(); + + // Check that syntax highlighting classes are present (Prism uses .token, hljs uses .hljs) + const hasHighlight = await page.evaluate(() => { + const code = document.querySelector('pre code'); + return code && ( + code.classList.contains('hljs') || + code.querySelector('.hljs-keyword') || + code.querySelector('.token') || + code.classList.contains('language-csharp') || + code.classList.contains('language-bash') + ); + }); + expect(hasHighlight).toBeTruthy(); + }); + + test('footer renders with all sections', async ({ page }) => { + await page.goto(baseUrl); + const footer = page.locator('footer'); + await expect(footer).toBeVisible(); + + // Check footer has links + const footerLinks = footer.locator('a'); + const linkCount = await footerLinks.count(); + expect(linkCount).toBeGreaterThan(5); + }); + + test('theme toggle button exists', async ({ page }) => { + await page.goto(baseUrl); + const themeToggle = page.locator('#theme-toggle'); + await expect(themeToggle).toBeVisible(); + }); + + test('language switcher exists and works', async ({ page }) => { + await page.goto(baseUrl); + const langSwitcher = page.locator('.language-switcher'); + if (await langSwitcher.count() > 0) { + await expect(langSwitcher).toBeVisible(); + } + }); + + test('Chinese pages load correctly', async ({ page }) => { + await page.goto(`${baseUrl}/zh/`); + await expect(page).toHaveURL(/\/zh\//); + + // Check lang attribute + const html = page.locator('html'); + await expect(html).toHaveAttribute('lang', 'zh'); + }); + + test('mobile menu toggle exists on mobile viewport', async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + await page.goto(baseUrl); + + // Mobile menu toggle should be visible on mobile + const mobileToggle = page.locator('#mobile-menu-toggle, .mobile-menu-toggle, [aria-label*="menu"]'); + // This may or may not be visible depending on CSS - just check page loads + await expect(page.locator('.hero')).toBeVisible(); + }); + + test('blog posts have proper structure', async ({ page }) => { + await page.goto(`${baseUrl}/blog/`); + const posts = page.locator('article, .post, .blog-post'); + + // Should have blog posts + const postCount = await posts.count(); + expect(postCount).toBeGreaterThan(0); + }); + + test('API reference pages load', async ({ page }) => { + await page.goto(`${baseUrl}/api/`); + await expect(page.locator('h1')).toBeVisible(); + + // Navigate to a specific API page + await page.goto(`${baseUrl}/api/httpclient-extensions/`); + await expect(page.locator('h1')).toBeVisible(); + }); + + test('examples page loads', async ({ page }) => { + await page.goto(`${baseUrl}/examples/`); + await expect(page.locator('h1')).toBeVisible(); + }); +});