diff --git a/CHANGELOG.md b/CHANGELOG.md index 857a0a0..8fa32a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,33 @@ +## 3.2.0 + +### New Features + +- **`isError` getter**: Convenience check for any client or server error (4xx or 5xx). Equivalent to `isClientError || isServerError`. + + ```dart + print(404.isError); // true + print(500.isError); // true + print(200.isError); // false + ``` + +- **Packaged AI assets** (`extension/mcp/`): The package now ships MCP-compatible resources and prompts for AI coding agents, following the [Dart/Flutter packaged AI assets proposal](https://flutter.dev/go/packaged-ai-assets). +Once the Dart MCP server implements the proposal, agents will automatically have access to: + - `@functional_status_codes/api_reference` — full API cheat-sheet + - `@functional_status_codes/patterns` — idiomatic usage patterns + - `/functional_status_codes/handle_response` — prompt to generate HTTP response handling code + - `/functional_status_codes/cache_strategy` — prompt to derive a caching strategy from a status code + +### Fixes + +- Fixed: `maybeMapStatusCode` now performs the `null` check before calling `toRegisteredStatusCode()`, which was previously invoked unnecessarily for `null` inputs. +- Fixed: `StatusCode.random()` no longer wraps the source iterable in an intermediate `List.unmodifiable(...)` just to call `elementAt`. The allocation was unnecessary since `Iterable.elementAt` works directly. +- Fixed: `StatusCode.pattern` (and `StatusCode.regExp`) now matches only the valid HTTP status code range (`[1-5]\d{2}`, i.e. 100-599) instead of any three-digit number (`\d{3}`). This avoids false matches on strings like `"timeout after 999ms"`. Note: `tryParse` behavior is unchanged for all registered codes — only the raw pattern/regex changes. +- Fixed: `values` doc comment count corrected from 95 to 93. + +### Documentation + +- `maybeMapStatusCode` and `maybeWhenStatusCode`: added explicit note that the `isStatusCode` handler takes priority over all category handlers (`isSuccess`, `isClientError`, etc.) when both are provided. + ## 3.1.0 This release adds several new features for better HTTP status code handling, including cacheable/retryable checks, pre-sorted category collections, and a random status code generator. Also includes important bug fixes. diff --git a/README.md b/README.md index e4573ea..ad3e75c 100644 --- a/README.md +++ b/README.md @@ -173,7 +173,3 @@ For more information on using this package, check out the API documentation and If you like this package, please give it a star or a like. For more information on using this package, check out the API documentation. **PRs or ideas are always welcome**. If you have any issues or suggestions for the package, please file them in the GitHub repository. - - -## License -[![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Ftsinis%2Ffunctional_status_codes.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Ftsinis%2Ffunctional_status_codes?ref=badge_large) diff --git a/extension/mcp/config.yaml b/extension/mcp/config.yaml new file mode 100644 index 0000000..16cb00f --- /dev/null +++ b/extension/mcp/config.yaml @@ -0,0 +1,35 @@ +resources: + - title: "API Reference" + description: "Concise cheat-sheet for all StatusCode constants, getters, methods, + and functional helpers. Include in agent context when working with HTTP status codes." + path: extension/mcp/resources/api_reference.md + + - title: "Functional Patterns Guide" + description: "Idiomatic patterns for when/map/maybeWhen/const variants, + isCacheable, isRetryable, and real-world HTTP client integration." + path: extension/mcp/resources/patterns.md + +prompts: + - title: "Handle HTTP Response" + description: "Generate idiomatic functional_status_codes code to handle an HTTP + response. Provide http_client (http, dio, or native) and optionally status_code." + path: extension/mcp/prompts/handle_response.md + arguments: + - name: http_client + description: "HTTP client in use: 'http', 'dio', or 'native' (dart:io)" + required: false + - name: status_code + description: "Specific status code to handle (e.g. 404). Leave blank for general handling." + required: false + + - title: "Cache Strategy" + description: "Derive a caching strategy for an HTTP response using isCacheable + and RFC 7231 semantics." + path: extension/mcp/prompts/cache_strategy.md + arguments: + - name: status_code + description: "HTTP status code to evaluate (e.g. 200)" + required: true + - name: cache_backend + description: "Cache storage backend being used, if any" + required: false diff --git a/extension/mcp/prompts/cache_strategy.md b/extension/mcp/prompts/cache_strategy.md new file mode 100644 index 0000000..609a0dd --- /dev/null +++ b/extension/mcp/prompts/cache_strategy.md @@ -0,0 +1,51 @@ +Derive a caching strategy for HTTP status code **{{status_code}}** using the `functional_status_codes` package and RFC 7231 semantics. + +{{#cache_backend}} +The cache backend in use is: **{{cache_backend}}** +{{/cache_backend}} +{{^cache_backend}} +Use a generic `cache.store(key, value, {Duration? ttl})` / `cache.get(key)` interface in the example. The caller can adapt it to their backend. +{{/cache_backend}} + +Follow these steps: + +1. **Evaluate cacheability** using `.isCacheable`: + + The RFC 7231 cacheable set is: `200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501`. + + ```dart + if ({{status_code}}.isCacheable) { + // safe to cache the response body + } + ``` + +2. **Assign a cache TTL** appropriate for code `{{status_code}}`: + - `200 OK` — moderate TTL (e.g. 5 minutes), respect `Cache-Control` headers when present + - `301 Moved Permanently` — long TTL (e.g. 1 day), permanent redirect is stable + - `404 Not Found` — short negative-cache TTL (e.g. 1-10 minutes) to avoid repeated misses + - `405 Method Not Allowed`, `410 Gone` — moderate TTL, these are stable server decisions + - `501 Not Implemented` — long TTL, unlikely to change soon + - `204`, `203`, `206`, `300`, `414` — short to moderate TTL depending on use case + + Use `whenConstOrNull` to express this as a compile-time mapping (`whenConstOrNull` is on `StatusCode`, so convert from raw `int` first): + + ```dart + final ttl = rawStatusCode.toRegisteredStatusCode()?.whenConstOrNull( + okHttp200: Duration(minutes: 5), + noContentHttp204: Duration(minutes: 1), + notFoundHttp404: Duration(minutes: 10), + movedPermanentlyHttp301: Duration(days: 1), + goneHttp410: Duration(hours: 1), + notImplementedHttp501: Duration(hours: 6), + ); + ``` + +3. **Generate the store/skip logic**: + - If `isCacheable` is `true` for `{{status_code}}`: generate a `cache.store(...)` call{{#cache_backend}} adapted to **{{cache_backend}}**{{/cache_backend}} + - If `isCacheable` is `false`: explain why this code should not be cached and suggest adding a `Cache-Control: no-store` header to the response if you control the server + +4. **Emit a complete, reusable function** that: + - Checks `isStatusCode` before doing anything (guards against invalid codes) + - Uses `isCacheable` to gate the write + - Uses `toRegisteredStatusCode()?.whenConstOrNull(...)` for TTL assignment — convert first since these methods are on `StatusCode`, not raw `int` (no runtime allocations) + - Falls back gracefully for non-cacheable codes (log and skip, do not throw) diff --git a/extension/mcp/prompts/handle_response.md b/extension/mcp/prompts/handle_response.md new file mode 100644 index 0000000..b734c6e --- /dev/null +++ b/extension/mcp/prompts/handle_response.md @@ -0,0 +1,45 @@ +Generate idiomatic Dart code to handle an HTTP response using the `functional_status_codes` package. + +{{#http_client}} +The HTTP client in use is: **{{http_client}}** +{{/http_client}} +{{^http_client}} +Detect the HTTP client from the existing code (look for `http.Response`, `dio.Response`, `HttpClientResponse`, or a plain `int` status code field). +{{/http_client}} + +{{#status_code}} +Focus specifically on handling status code **{{status_code}}** (in addition to general handling). +{{/status_code}} + +Follow these steps: + +1. **Extract the status code** as `int` or `num` from the response object: + - `package:http` → `response.statusCode` (already `int`) + - `package:dio` → `response.statusCode` (already `int?`) + - `dart:io HttpClientResponse` → `response.statusCode` (already `int`) + - Custom → use whatever field holds the integer status + +2. **Apply category-level branching** using `maybeWhenStatusCode` or `maybeMapStatusCode`: + - Use `maybeWhenStatusCode` when the callbacks do not need the code value passed in + - Use `maybeMapStatusCode` when you need `(code) =>` in the callback + - Provide `orElse` for codes outside 100-599 or unhandled categories + - Do **not** provide `isStatusCode` alongside category handlers unless you intentionally want it to catch all valid codes first + +3. **Drill into specific codes** when needed by converting first: + + ```dart + final registered = rawCode.toRegisteredStatusCode(); + final result = registered?.maybeMap( + orElse: (c) => defaultHandling(c), + notFoundHttp404: (_) => handleNotFound(), + ); + ``` + +4. **Emit null-safe, idiomatic Dart**: + - Prefer `?.` and `??` over null-checks + - Use `whenConstStatusCode` / `whenConstOrNull` when the return values are constants — these accept **direct values**, not closures (e.g. `isSuccess: 'ok'`, never `isSuccess: () => 'ok'`); they must be called on a `StatusCode` value after `toRegisteredStatusCode()` conversion + - Avoid `if (code >= 200 && code < 300)` — use `.isSuccess` / `.isError` / `.isCacheable` etc. + +5. **Handle the specific code** {{#status_code}}({{status_code}}){{/status_code}} with a dedicated branch inside `maybeMap` or `maybeWhen` on the `StatusCode`, using the constant name `Http`. + +Produce a complete, runnable function or method — not just a snippet — with appropriate return type and error handling. diff --git a/extension/mcp/resources/api_reference.md b/extension/mcp/resources/api_reference.md new file mode 100644 index 0000000..78b9259 --- /dev/null +++ b/extension/mcp/resources/api_reference.md @@ -0,0 +1,239 @@ +# functional_status_codes — API Reference + +## Import + +```dart +import 'package:functional_status_codes/functional_status_codes.dart'; +``` + +## StatusCode Type + +`StatusCode` is an **extension type** over `int` — zero-cost, no boxing at runtime. + +```dart +// Use predefined constants — naming: Http +const ok = StatusCode.okHttp200; // == 200 +const notFound = StatusCode.notFoundHttp404; // == 404 +const ise = StatusCode.internalServerErrorHttp500; + +// Custom code for non-standard/internal use +const custom = StatusCode.custom(456); + +// Interchangeable with int — no casting needed +void send(int code) {} +send(StatusCode.okHttp200); // valid + +// Convert from int +final code = StatusCode.maybeFromCode(response.statusCode); // StatusCode? +// ...93 predefined codes covering 1xx-5xx including common unofficial codes +``` + +## Static Collections + +```dart +StatusCode.values // All 93 known codes (List) +StatusCode.officialCodes // IANA-registered + widely adopted unofficial codes +StatusCode.informationalCodes // 1xx +StatusCode.successCodes // 2xx +StatusCode.redirectionCodes // 3xx +StatusCode.clientErrorCodes // 4xx +StatusCode.serverErrorCodes // 5xx +StatusCode.cacheableCodes // RFC 7231: 200,203,204,206,300,301,404,405,410,414,501 +StatusCode.retryableCodes // Transient: 408,425,429,500,502,503,504,511,598,599 +``` + +## Static Methods + +```dart +StatusCode.random() // random from all values +StatusCode.random(from: StatusCode.serverErrorCodes) // random from subset +StatusCode.tryParse('Response: 404 Not Found') // → StatusCode? (first match) +StatusCode.maybeFromCode(200) // → StatusCode.okHttp200 +StatusCode.maybeFromCode(999) // → null (unregistered) +StatusCode.maybeFromCode(null) // → null +``` + +## num? Extension — NumStatusCodeExtension + +Applies to any `num?`, including raw `int` from HTTP clients. + +### Range and Category Checks + +```dart +200.isStatusCode // true — within 100-599 +999.isStatusCode // false +200.isInformational // false +200.isSuccess // true +301.isRedirection // true +404.isClientError // true +500.isServerError // true +404.isError // true — isClientError || isServerError +200.isError // false +``` + +### Semantic Checks + +```dart +200.isCacheable // true (RFC 7231) +201.isCacheable // false +503.isRetryable // true +200.isRetryable // false +200.isOneOf(StatusCode.cacheableCodes) // true +200.isOneOf(const [StatusCode.okHttp200, StatusCode.custom(222)]) // true +``` + +### Range Helpers + +```dart +// Custom numeric range +200.isStatusCodeWithinRange(min: 200, max: 299) // true + +// StatusCode-bounded range +200.isStatusWithinRange( + min: StatusCode.okHttp200, + max: StatusCode.imUsedHttp226, +) // true +``` + +### Conversion + +```dart +200.toRegisteredStatusCode() // → StatusCode.okHttp200 +299.toRegisteredStatusCode() // → null (not a registered code) +null.toRegisteredStatusCode() // → null +``` + +### Functional Methods on num? + +```dart +// Exhaustive category dispatch — throws FormatException if out of range +response.statusCode.mapStatusCode( + isInformational: (code) => ..., + isSuccess: (code) => ..., + isRedirection: (code) => ..., + isClientError: (code) => ..., + isServerError: (code) => ..., +) + +// Optional category dispatch — fallback to orElse +response.statusCode.maybeMapStatusCode( + orElse: (code) => handleUnknown(code), + isSuccess: (code) => handleSuccess(code), + isClientError: (code) => handleClientError(code), + // unspecified categories fall to orElse +) +// NOTE: isStatusCode handler (if provided) fires before any category handler. + +// Zero-arg variants (no value passed to callbacks) +response.statusCode.maybeWhenStatusCode( + orElse: () => false, + isSuccess: () => true, +) + +// Const-value variant — all 5 categories required, no orElse, asserts in-range +response.statusCode.whenConstStatusCode( + isInformational: 'info', + isSuccess: 'ok', + isRedirection: 'redirect', + isClientError: 'client error', + isServerError: 'server error', +) + +// Nullable variant — all params optional, orElse is also optional +response.statusCode.whenConstStatusCodeOrNull( + isSuccess: 'ok', + isClientError: 'client error', + orElse: 'unknown', +) + +// Convert then map per category in one step +response.statusCode.mapToRegisteredStatusCode( + isInformational: (code) => code?.reason ?? 'info', + isSuccess: (code) => code?.reason ?? 'success', + isRedirection: (code) => code?.reason ?? 'redirect', + isClientError: (code) => code?.reason ?? 'client error', + isServerError: (code) => code?.reason ?? 'server error', +) +``` + +## StatusCode Extension — StatusCodeExtension + +Applies to `StatusCode` values after conversion. + +### Properties + +```dart +StatusCode.okHttp200.reason // "OK" +StatusCode.notFoundHttp404.reason // "Not Found" + +StatusCode.okHttp200.isOfficial // true — IANA registered +StatusCode.custom(456).isOfficial // false +StatusCode.okHttp200.isCustom // false +StatusCode.custom(456).isCustom // true + +StatusCode.okHttp200.toStringShallow() +// → 'StatusCode(200, reason: "OK", isOfficial: true)' +``` + +### Individual Code Checks + +One `bool` getter per registered code: + +```dart +code.isOkHttp200 +code.isNotFoundHttp404 +code.isInternalServerErrorHttp500 +code.isUnauthorizedHttp401 +// ...one per registered constant +``` + +### Functional Methods on StatusCode + +```dart +// Exhaustive — must handle all 93 codes + wildcard fires for custom codes +statusCode.when( + okHttp200: () => 'success', + notFoundHttp404: () => 'missing', + // ...all codes required +) + +// Optional — return null for unhandled codes +statusCode.whenOrNull( + okHttp200: () => 'success', + notFoundHttp404: () => 'missing', +) + +// Optional with fallback +statusCode.maybeWhen( + orElse: () => 'other', + okHttp200: () => 'success', +) + +// map variants pass the StatusCode value to the callback +statusCode.maybeMap( + orElse: (code) => 'other: ${code.reason}', + okHttp200: (code) => 'success', +) + +// Const-value variant (no closures) +statusCode.whenConst( + okHttp200: 'success', + notFoundHttp404: 'missing', + // ...all codes required +) + +// Const + nullable +statusCode.whenConstOrNull( + okHttp200: 'success', + notFoundHttp404: 'missing', + // unspecified → null +) +``` + +## Dart 3.10 Dot-Shorthand + +```dart +// Enum-like dot syntax on static constants +200.isOneOf(const [.okHttp200, .custom(222)]) // true +response.statusCode.toRegisteredStatusCode() == .notFoundHttp404 +``` diff --git a/extension/mcp/resources/patterns.md b/extension/mcp/resources/patterns.md new file mode 100644 index 0000000..6079f86 --- /dev/null +++ b/extension/mcp/resources/patterns.md @@ -0,0 +1,215 @@ +# functional_status_codes — Functional Patterns Guide + +## 1. Handling Any num Response Code + +Use `maybeWhenStatusCode` or `maybeMapStatusCode` directly on the raw `int` from any HTTP client — no conversion required. + +```dart +import 'package:functional_status_codes/functional_status_codes.dart'; + +// Works with package:http, Dio, dart:io, or any int status code +T? handleResponse(int statusCode, T Function() parseBody) { + return statusCode.maybeMapStatusCode( + orElse: (code) { + throw StateError('Unexpected status $code'); + }, + isSuccess: (_) => parseBody(), + isClientError: (code) { + if (code == StatusCode.notFoundHttp404) return null; + throw ArgumentError('Client error: $code'); + }, + isServerError: (_) => null, // caller can retry + ); +} +``` + +**Note:** If you provide `isStatusCode` alongside a category handler, `isStatusCode` always fires first for any valid code (100-599). Omit it when you want category-specific matching. + +--- + +## 2. Specific Code Matching + +Convert to a `StatusCode` first, then use `maybeMap` or `maybeWhen` to match individual codes. + +```dart +StatusCode? code = rawStatusCode.toRegisteredStatusCode(); + +final message = code?.maybeMap( + orElse: (c) => 'HTTP ${c.reason}', + notFoundHttp404: (_) => 'Resource not found — check the URL', + unauthorizedHttp401: (_) => 'Please sign in', + forbiddenHttp403: (_) => 'You do not have access', + tooManyRequestsHttp429: (_) => 'Rate limited — slow down', + serviceUnavailableHttp503: (_) => 'Service down — try later', +) ?? 'Unknown status $rawStatusCode'; +``` + +Use `whenOrNull` when you only care about a few codes and want `null` for the rest: + +```dart +final retryDelay = code?.whenOrNull( + tooManyRequestsHttp429: () => Duration(seconds: 60), + serviceUnavailableHttp503: () => Duration(seconds: 5), + gatewayTimeoutHttp504: () => Duration(seconds: 2), +); +// null → do not retry +``` + +--- + +## 3. Const Mapping (Zero Allocations) + +Use `whenConst` / `whenConstOrNull` when mapping to constant values. No closures are created. + +```dart +// Map every status code to a log level at compile time +const level = statusCode.whenConst( + okHttp200: 'info', + createdHttp201: 'info', + noContentHttp204: 'info', + notFoundHttp404: 'warn', + badRequestHttp400: 'warn', + unauthorizedHttp401: 'warn', + internalServerErrorHttp500: 'error', + serviceUnavailableHttp503: 'error', + // ...all 93 codes required +); + +// Or just the codes you care about — null for everything else +final icon = statusCode.whenConstOrNull( + okHttp200: '✓', + notFoundHttp404: '✗', + internalServerErrorHttp500: '!', +); +``` + +Category-level const mapping via `whenConstStatusCodeOrNull`: + +```dart +final label = rawCode.whenConstStatusCodeOrNull( + isInformational: 'informational', + isSuccess: 'success', + isRedirection: 'redirect', + isClientError: 'client error', + isServerError: 'server error', + orElse: 'unknown', +); +``` + +--- + +## 4. Caching Logic (RFC 7231) + +`isCacheable` returns `true` for the RFC 7231 cacheable set: +`200, 203, 204, 206, 300, 301, 404, 405, 410, 414, 501`. + +```dart +Future fetchAndCache(String key, Future Function() fetch) async { + final response = await fetch(); + final code = response.statusCode; + + if (!code.isStatusCode) { + throw FormatException('Invalid status code: $code'); + } + + if (code.isCacheable) { + await cache.store(key, response.body); + } +} +``` + +Pair with `whenConst` to assign cache TTLs per code: + +```dart +final ttl = statusCode.whenConstOrNull( + okHttp200: Duration(minutes: 5), + noContentHttp204: Duration(minutes: 1), + notFoundHttp404: Duration(minutes: 10), // negative-cache 404s + movedPermanentlyHttp301: Duration(days: 1), +); + +if (ttl != null) await cache.store(key, body, ttl: ttl); +``` + +--- + +## 5. Retry Logic + +`isRetryable` returns `true` for transient errors: +`408, 425, 429, 500, 502, 503, 504, 511, 598, 599`. + +```dart +Future fetchWithRetry( + Uri uri, { + int maxAttempts = 3, + Duration baseDelay = const Duration(seconds: 1), +}) async { + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + final response = await http.get(uri); + + if (!response.statusCode.isRetryable) return response; + + if (attempt == maxAttempts) return response; + + // Exponential backoff + await Future.delayed(baseDelay * (1 << (attempt - 1))); + } + throw StateError('unreachable'); +} +``` + +Combine `isCacheable` and `isRetryable` with category checks: + +```dart +response.statusCode.maybeWhenStatusCode( + orElse: () => Response.error(), + isSuccess: () => processResponse(response), + isClientError: () { + if (response.statusCode.isRetryable) scheduleRetry(); + return Response.clientError(response.statusCode); + }, + isServerError: () { + if (response.statusCode.isRetryable) scheduleRetry(); + return Response.serverError(response.statusCode); + }, +); +``` + +--- + +## 6. Testing with Random Codes + +`StatusCode.random()` generates random codes from any collection — ideal for property-based tests. + +```dart +import 'package:test/test.dart'; +import 'package:functional_status_codes/functional_status_codes.dart'; + +test('handler returns null for 404 Not Found', () { + expect(handleResponse(StatusCode.notFoundHttp404, () => 'body'), isNull); +}); + +test('cache is written for any cacheable code', () async { + for (var i = 0; i < 20; i++) { + final code = StatusCode.random(from: StatusCode.cacheableCodes); + expect(code.isCacheable, isTrue); + await fetchAndCache('key', () async => Response(statusCode: code)); + expect(cache.has('key'), isTrue); + } +}); + +test('retry is attempted for retryable server errors', () { + for (var i = 0; i < 30; i++) { + final code = StatusCode.random(from: StatusCode.retryableCodes); + expect(shouldRetry(code), isTrue); + } +}); + +// Custom codes in the 5xx range hit isServerError → null +test('custom server error is handled gracefully', () { + final unknown = StatusCode.custom(555); // valid: not registered, within 103–598 + expect(unknown.isCustom, isTrue); + expect(unknown.toRegisteredStatusCode(), isNull); + expect(handleResponse(unknown, () => 'body'), isNull); // hits isServerError +}); +``` diff --git a/lib/src/num_status_code_extension.dart b/lib/src/num_status_code_extension.dart index f338ef0..2f015e2 100644 --- a/lib/src/num_status_code_extension.dart +++ b/lib/src/num_status_code_extension.dart @@ -9,7 +9,7 @@ import 'status_code.dart'; /// Extension on [num?] types to provide additional functionality when working /// with HTTP status codes. extension NumStatusCodeExtension on T? { - static const _outSideOfRangeMessage = + static const _outsideOfRangeMessage = // ignore: avoid-adjacent-strings, it's not being used on one line strings. 'Value is outside of ' '''${StatusCode.continueHttp100}-${StatusCode.networkConnectTimeoutErrorHttp599}''' @@ -216,6 +216,18 @@ extension NumStatusCodeExtension on T? { bool get isServerError => isStatusWithinRange(min: StatusCode.internalServerErrorHttp500); + /// Returns `true` if the value is within the range of client or server error + /// HTTP status codes (400-599), `false` otherwise. + /// + /// Example: + /// + /// ```dart + /// print(400.isError); // true + /// print(500.isError); // true + /// print(200.isError); // false + /// ``` + bool get isError => isClientError || isServerError; + /// Converts the [num?] value to a [StatusCode] if it exists within the range /// of valid HTTP status codes (100-599), or returns `null` if it is outside /// of that range or `null`. @@ -260,7 +272,7 @@ extension NumStatusCodeExtension on T? { }) { assert( isStatusCode, - '$_outSideOfRangeMessage. Consider using maybeMapStatusCode() instead', + '$_outsideOfRangeMessage. Consider using maybeMapStatusCode() instead', ); final thisValue = this; if (thisValue == null) throw FormatException('Null value provided!', this); @@ -269,13 +281,18 @@ extension NumStatusCodeExtension on T? { if (thisValue.isRedirection) return isRedirection(thisValue); if (thisValue.isClientError) return isClientError(thisValue); if (thisValue.isServerError) return isServerError(thisValue); - throw FormatException(_outSideOfRangeMessage, thisValue); + throw FormatException(_outsideOfRangeMessage, thisValue); } /// If the [num?] value is a valid HTTP status code, maps it to a result using /// the provided functions. If the value is not a valid HTTP status code, /// returns the result of calling [orElse]. /// + /// **Handler priority:** [isStatusCode] is checked first and matches any + /// valid HTTP status code (100-599). If provided alongside a category handler + /// (e.g. [isSuccess]), [isStatusCode] will always fire instead. Only omit + /// [isStatusCode] when you want category-specific matching. + /// /// Example: /// /// ```dart @@ -323,6 +340,11 @@ extension NumStatusCodeExtension on T? { /// redirection, client error, or server error). If the value is not a valid /// HTTP status code, returns the result of calling [orElse]. /// + /// **Handler priority:** [isStatusCode] is checked first and matches any + /// valid HTTP status code (100-599). If provided alongside a category handler + /// (e.g. [isSuccess]), [isStatusCode] will always fire instead. Only omit + /// [isStatusCode] when you want category-specific matching. + /// /// Example: /// /// ```dart @@ -391,14 +413,14 @@ extension NumStatusCodeExtension on T? { }) { assert( isStatusCode, - '$_outSideOfRangeMessage. Consider using maybeWhenStatusCode() instead', + '$_outsideOfRangeMessage. Consider using maybeWhenStatusCode() instead', ); if (this.isInformational) return isInformational(); if (this.isSuccess) return isSuccess(); if (this.isRedirection) return isRedirection(); if (this.isClientError) return isClientError(); if (this.isServerError) return isServerError(); - throw FormatException(_outSideOfRangeMessage, this); + throw FormatException(_outsideOfRangeMessage, this); } /// Evaluates the provided functions based on the HTTP status code represented @@ -471,14 +493,14 @@ extension NumStatusCodeExtension on T? { }) { assert( isStatusCode, - '''$_outSideOfRangeMessage. Consider using whenConstStatusCodeOrNull() instead''', + '''$_outsideOfRangeMessage. Consider using whenConstStatusCodeOrNull() instead''', ); if (this.isInformational) return isInformational; if (this.isSuccess) return isSuccess; if (this.isRedirection) return isRedirection; if (this.isClientError) return isClientError; if (this.isServerError) return isServerError; - throw FormatException(_outSideOfRangeMessage, this); + throw FormatException(_outsideOfRangeMessage, this); } /// A [Map] like equivalent of [whenStatusCodeOrNull] method. @@ -552,7 +574,7 @@ extension NumStatusCodeExtension on T? { }) { assert( isStatusCode, - '''$_outSideOfRangeMessage. Consider using maybeMapToRegisteredStatusCode() instead''', + '''$_outsideOfRangeMessage. Consider using maybeMapToRegisteredStatusCode() instead''', ); final thisValue = this; if (thisValue == null) throw FormatException('Null value provided!', this); @@ -564,7 +586,7 @@ extension NumStatusCodeExtension on T? { if (thisValue.isClientError) return isClientError(registeredStatusCode); if (thisValue.isServerError) return isServerError(registeredStatusCode); - throw FormatException(_outSideOfRangeMessage, thisValue); + throw FormatException(_outsideOfRangeMessage, thisValue); } /// If the [num?] value is a valid HTTP status code, maps it to a result using @@ -598,8 +620,8 @@ extension NumStatusCodeExtension on T? { R Function(StatusCode? serverErrorStatusCode)? isServerError, }) { final thisValue = this; + if (thisValue == null) return orElse(null, thisValue); final registeredStatusCode = thisValue.toRegisteredStatusCode(); - if (thisValue == null) return orElse(registeredStatusCode, thisValue); if (thisValue.isStatusCode && isStatusCode != null) { return isStatusCode(registeredStatusCode); diff --git a/lib/src/status_code.dart b/lib/src/status_code.dart index 8e32548..3c8e8a1 100644 --- a/lib/src/status_code.dart +++ b/lib/src/status_code.dart @@ -683,49 +683,53 @@ extension type const StatusCode._(int _code) implements int { /// in front of the proxy. static const networkConnectTimeoutErrorHttp599 = StatusCode._(599); - /// A regular expression pattern that matches three consecutive digits. + /// A regular expression pattern that matches a valid HTTP status code range. /// /// This pattern is commonly used to identify HTTP status codes within a /// string. HTTP status codes are typically 3-digit integers ranging from 100 - /// to 599. The pattern is defined by the regular expression `\d{3}`, where - /// `\d` stands for any digit, and `{3}` specifies exactly three occurrences - /// of the digit. + /// to 599. The pattern is defined by the regular expression + /// `(? from = values, Random? random, }) { - assert(from.isNotEmpty, 'The provided `from` iterable must not be empty'); - final elementAt = (random ?? Random()).nextInt(from.length); + final list = from is List ? from : from.toList(growable: false); + assert(list.isNotEmpty, 'The provided `from` iterable must not be empty'); + final elementAt = (random ?? Random()).nextInt(list.length); // ignore: avoid-unsafe-collection-methods, length is guaranteed to be > 0. - return List.unmodifiable(from).elementAt(elementAt); + return list[elementAt]; } } diff --git a/pubspec.yaml b/pubspec.yaml index ee505e0..fe0966c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,4 +1,4 @@ -version: 3.1.0 +version: 3.2.0 name: functional_status_codes description: Zero-cost HTTP status codes as int extension type with num? functional helpers maintainer: Roman Cinis diff --git a/test/src/num_status_code_extension_test.dart b/test/src/num_status_code_extension_test.dart index ef8034c..95c6010 100644 --- a/test/src/num_status_code_extension_test.dart +++ b/test/src/num_status_code_extension_test.dart @@ -231,6 +231,37 @@ void main() => group('NumStatusCodeExtension', () { } }); + group('isError', () { + const combinedErrorsTrueCases = { + StatusCode.badRequestHttp400, + StatusCode.badRequestHttp400 + 1, + StatusCode.nginxClientClosedRequestHttp499, + StatusCode.internalServerErrorHttp500, + StatusCode.internalServerErrorHttp500 + 1, + StatusCode.networkConnectTimeoutErrorHttp599, + }; + + const isErrorFalseCases = { + StatusCode.okHttp200, + StatusCode.multipleChoicesHttp300, + StatusCode.networkConnectTimeoutErrorHttp599 + 1, + }; + + for (final number in [...globalWrongCases, ...isErrorFalseCases]) { + test( + 'should return false for $number', + () => expect(number.isError, isFalse), + ); + } + + for (final number in combinedErrorsTrueCases) { + test( + 'should return true for $number', + () => expect(number.isError, isTrue), + ); + } + }); + group('toRegisteredStatusCode', () { for (final number in globalWrongCases) { test( diff --git a/test/src/status_code_test.dart b/test/src/status_code_test.dart index 9c07489..9c6f5e4 100644 --- a/test/src/status_code_test.dart +++ b/test/src/status_code_test.dart @@ -215,8 +215,9 @@ void main() => group('$StatusCode', () { ); test( - 'should extract first three-digit match from longer number', - // '12003' matches '120' first — not a registered code. + 'should return null for longer number with embedded valid range digits', + // '12003': lookahead/lookbehind prevents matching '120' (followed by digit) + // or '200' (preceded by digit) — no isolated [1-5]xx sequence found. () => expect(StatusCode.tryParse('12003'), isNull), ); @@ -232,6 +233,58 @@ void main() => group('$StatusCode', () { ); }); + group('pattern and regExp', () { + test( + 'pattern matches the expected regex string', + () => expect(StatusCode.pattern, r'(? expect(StatusCode.regExp, isA()), + ); + + test( + 'regExp matches a bare valid code', + () => expect(StatusCode.regExp.hasMatch('200'), isTrue), + ); + + test( + 'regExp matches a code preceded by non-digit characters', + () => expect(StatusCode.regExp.hasMatch('status: 404.'), isTrue), + ); + + test( + 'regExp matches a code followed by non-digit characters', + () => expect(StatusCode.regExp.hasMatch('200 OK'), isTrue), + ); + + test( + 'regExp does not match a 6xx code', + () => expect(StatusCode.regExp.hasMatch('600'), isFalse), + ); + + test( + 'regExp does not match a 9xx code', + () => expect(StatusCode.regExp.hasMatch('999'), isFalse), + ); + + test( + 'regExp does not match a valid-range code embedded in a longer number', + () => expect(StatusCode.regExp.hasMatch('12003'), isFalse), + ); + + test( + 'regExp does not match when code is preceded by a digit', + () => expect(StatusCode.regExp.hasMatch('1200'), isFalse), + ); + + test( + 'regExp does not match when code is followed by a digit', + () => expect(StatusCode.regExp.hasMatch('2001'), isFalse), + ); + }); + group('custom constructor edge cases', () { test('valid custom code in gap between standard codes', () { const custom = StatusCode.custom(109);