diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md new file mode 100644 index 0000000..f5c1f87 --- /dev/null +++ b/.cursor/rules/README.md @@ -0,0 +1,5 @@ +# Cursor (optional) + +**Cursor** users: start at **[AGENTS.md](../../AGENTS.md)**. All conventions live in **`skills/*/SKILL.md`**. + +This folder only points contributors to **`AGENTS.md`** so editor-specific config does not duplicate the canonical docs. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d8381ca --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,49 @@ +# Contentstack .NET SDK – Agent guide + +**Universal entry point** for contributors and AI agents. Each skill is documented in **`skills/*/SKILL.md`** (YAML frontmatter for agent discovery where applicable). + +## What this repo is + +| Field | Detail | +|-------|--------| +| **Name:** | [contentstack-dotnet](https://github.com/contentstack/contentstack-dotnet) | +| **Purpose:** | .NET SDK for Contentstack’s Content Delivery API (CDA)—fetch entries, assets, and run queries from .NET apps. | +| **Out of scope (if any):** | Do not bypass the SDK HTTP layer with ad-hoc `HttpClient` usage; all requests go through `HttpRequestHandler` (see `skills/sdk-core-patterns/SKILL.md`). | + +## Tech stack (at a glance) + +| Area | Details | +|------|---------| +| Language | C#; multi-targeting includes `netstandard2.0`, `net47`, `net472` (see project files under `Contentstack.Core/`). | +| Build | .NET SDK — solution [`Contentstack.Net.sln`](Contentstack.Net.sln); packages [`Contentstack.Core/`](Contentstack.Core/) (Delivery SDK), [`Contentstack.AspNetCore/`](Contentstack.AspNetCore/) (DI extensions). | +| Tests | xUnit; unit tests in [`Contentstack.Core.Unit.Tests/`](Contentstack.Core.Unit.Tests/) (no credentials); integration tests in [`Contentstack.Core.Tests/`](Contentstack.Core.Tests/) (requires `app.config` / API credentials). | +| Lint / coverage | No dedicated repo-wide lint/format CLI in CI. Security/static analysis: [CodeQL workflow](.github/workflows/codeql-analysis.yml). | +| Other | JSON: Newtonsoft.Json; package version: single source in [`Directory.Build.props`](Directory.Build.props). | + +## Commands (quick reference) + +| Command type | Command | +|--------------|---------| +| Build | `dotnet build Contentstack.Net.sln` | +| Test (unit) | `dotnet test Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj` | +| Test (integration) | `dotnet test Contentstack.Core.Tests/Contentstack.Core.Tests.csproj` (configure credentials locally) | + +CI: [`.github/workflows/unit-test.yml`](.github/workflows/unit-test.yml) restores, builds, and runs unit tests on Windows (.NET 7). Other workflows include [NuGet publish](.github/workflows/nuget-publish.yml), [branch checks](.github/workflows/check-branch.yml), [CodeQL](.github/workflows/codeql-analysis.yml), policy/SCA scans. + +## Where the documentation lives: skills + +| Skill | Path | What it covers | +|-------|------|----------------| +| Dev workflow | [`skills/dev-workflow/SKILL.md`](skills/dev-workflow/SKILL.md) | Solution layout, build/test commands, versioning, CI entry points. | +| SDK core patterns | [`skills/sdk-core-patterns/SKILL.md`](skills/sdk-core-patterns/SKILL.md) | Architecture, `ContentstackClient`, HTTP layer, DI, plugins. | +| Query building | [`skills/query-building/SKILL.md`](skills/query-building/SKILL.md) | Fluent query API, operators, pagination, sync, taxonomy. | +| Models and serialization | [`skills/models-and-serialization/SKILL.md`](skills/models-and-serialization/SKILL.md) | Entry/Asset models, JSON converters, collections. | +| Error handling | [`skills/error-handling/SKILL.md`](skills/error-handling/SKILL.md) | Exception hierarchy, `ErrorMessages`, API error parsing. | +| Testing | [`skills/testing/SKILL.md`](skills/testing/SKILL.md) | Unit vs integration tests, AutoFixture, `IntegrationTestBase`. | +| Code review | [`skills/code-review/SKILL.md`](skills/code-review/SKILL.md) | PR checklist for this SDK. | + +An index with “when to use” hints is in [`skills/README.md`](skills/README.md). + +## Using Cursor (optional) + +If you use **Cursor**, [`.cursor/rules/README.md`](.cursor/rules/README.md) only points to **`AGENTS.md`**—the same conventions as for everyone else. Canonical guidance remains in **`skills/*/SKILL.md`**. diff --git a/README.md b/README.md index ea06383..a07ff77 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ .NET SDK for Contentstack's Content Delivery API +Contributor and agent conventions: see **[AGENTS.md](AGENTS.md)**. + ## Getting Started This guide will help you get started with our .NET SDK to build apps powered by Contentstack. diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..f3e91cd --- /dev/null +++ b/skills/README.md @@ -0,0 +1,21 @@ +# Skills – Contentstack .NET SDK + +Source of truth for detailed guidance. Read **[AGENTS.md](../AGENTS.md)** first, then open the skill that matches your task. + +## When to use which skill + +| Skill folder | Use when | +|--------------|----------| +| `dev-workflow` | Building the solution, versioning, CI workflows, onboarding, PR prep. | +| `sdk-core-patterns` | Architecture, `ContentstackClient`, HTTP layer, DI, plugins, request flow. | +| `query-building` | Query operators, fluent API, pagination, sync, taxonomy, `Query.cs`. | +| `models-and-serialization` | Entry/Asset models, JSON converters, `ContentstackCollection`, serialization. | +| `error-handling` | Exceptions, `ErrorMessages`, parsing API errors. | +| `testing` | Writing or debugging unit/integration tests, coverage, test layout. | +| `code-review` | Reviewing a PR against SDK-specific checklist. | + +Each folder contains **`SKILL.md`** with YAML frontmatter (`name`, `description`) for agent discovery. + +### Cursor + +In Cursor, you can also reference a skill in chat with `@skills/` (for example `@skills/testing`). diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md new file mode 100644 index 0000000..9ea7904 --- /dev/null +++ b/skills/code-review/SKILL.md @@ -0,0 +1,223 @@ +--- +name: code-review +description: SDK-specific PR review checklist for the Contentstack .NET SDK — covers breaking changes, HTTP layer correctness, exception handling, serialization, fluent API patterns, configuration, test coverage, multi-targeting, plugin lifecycle, and internal visibility. Use when reviewing pull requests, examining code changes, or performing code quality assessments on this SDK. +--- + +# Code Review + +## When to use + +- Reviewing a PR or diff against SDK conventions. +- Self-review before opening a PR. + +## Severity levels + +- **Critical** — Must fix before merge (correctness, breaking changes, security) +- **Important** — Should fix (maintainability, SDK patterns, consistency) +- **Suggestion** — Consider improving (style, optimization) + +The sections below provide category-by-category checklists (breaking changes, HTTP, exceptions, serialization, fluent API, config, tests, multi-targeting, plugins, visibility) and a short red-flag list for quick scanning. + + +## Code Review Checklist + +### Breaking Changes Checklist + +```markdown +## Breaking Changes Review +- [ ] No public method signatures removed or changed without [Obsolete] deprecation +- [ ] No [JsonProperty] values changed (would break consumer deserialization silently) +- [ ] No ContentstackOptions public property removed +- [ ] New required options have defaults (don't break existing consumers who don't set them) +- [ ] No namespace renames without backward-compatible type aliases +- [ ] No IContentstackPlugin interface signature changed +- [ ] Version bump planned if breaking change is intentional (Directory.Build.props) +``` + +### HTTP Layer Checklist + +```markdown +## HTTP Layer Review +- [ ] All HTTP calls route through HttpRequestHandler.ProcessRequest +- [ ] No HttpClient instantiation anywhere in the PR +- [ ] New query params added to UrlQueries dict (not directly to URL string) +- [ ] New field-level filters added to QueryValueJson dict +- [ ] New headers added via Headers parameter to ProcessRequest +- [ ] Branch header uses "branch" key, passed as separate Branch parameter +- [ ] No hardcoded URLs — BaseUrl comes from Config.BaseUrl +- [ ] Live preview URL resolved via Config.getBaseUrl() — not hardcoded +- [ ] ProcessRequest result (string JSON) parsed, not further HTTP calls made +``` + +### Exception Handling Checklist + +```markdown +## Exception Handling Review +- [ ] Domain-specific exception type used (QueryFilterException, AssetException, etc.) +- [ ] No bare `throw new Exception(...)` or `throw new ContentstackException(...)` +- [ ] All message strings sourced from ErrorMessages.cs constants +- [ ] No string literals in throw statements +- [ ] GetContentstackError(ex) called when catching WebException from HTTP calls +- [ ] ErrorCode, StatusCode, Errors preserved when re-wrapping exceptions +- [ ] New domain area has new exception class with factory methods +- [ ] New error messages added to correct section in ErrorMessages.cs +- [ ] FormatExceptionDetails(innerEx) used in ProcessingError factory methods +``` + +### Serialization Checklist + +```markdown +## Serialization Review +- [ ] All public properties mapping CDA JSON fields have [JsonProperty("snake_case")] +- [ ] No reliance on default Newtonsoft.Json camelCase or PascalCase matching +- [ ] Custom deserialization uses [CSJsonConverter] + JsonConverter subclass +- [ ] JsonConverter placed in Contentstack.Core/Internals/ (internal class) +- [ ] No System.Text.Json usage +- [ ] No JsonConvert.DeserializeObject with hardcoded type outside of converter +- [ ] ContentstackCollection used for list responses (not List directly) +- [ ] "entries" token used for entry collection, "assets" for asset collection +``` + +### Fluent API Checklist + +```markdown +## Fluent API Review +- [ ] Every Query filter/operator method returns `return this;` +- [ ] Null key validated at start of method → QueryFilterException.Create() +- [ ] Empty string key validated → QueryFilterException.Create() +- [ ] Operator value stored in QueryValueJson[key][$operator] nested dict +- [ ] URL-level params stored in UrlQueries[key] +- [ ] Method name follows verb+noun pattern (GreaterThan, ContainedIn, NotExists) +- [ ] No mutation of QueryValueJson or UrlQueries outside of the Query class itself +- [ ] And()/Or() accept Query[] (not raw dictionaries) +``` + +### Configuration Checklist + +```markdown +## Configuration Review +- [ ] New options added to ContentstackOptions (public class), not Config (internal) +- [ ] New property has XML doc comment +- [ ] Default value set in ContentstackOptions() constructor or property initializer +- [ ] ContentstackClient constructor reads new option and passes to Config +- [ ] Config never exposed as public property +- [ ] New option tested in ContentstackOptionsUnitTests.cs +- [ ] ASP.NET Core binding works (IOptions path verified) +``` + +### Test Coverage Checklist + +```markdown +## Test Coverage Review +- [ ] Unit test for each new public Query method (QueryValueJson assertion via reflection) +- [ ] Unit test for null key input → QueryFilterException +- [ ] Unit test for empty key input → QueryFilterException +- [ ] Unit test for fluent return (Assert.Equal(query, result)) +- [ ] Integration test file in Integration/{FeatureName}Tests/ subfolder +- [ ] Integration test class extends IntegrationTestBase +- [ ] Integration test constructor takes ITestOutputHelper output +- [ ] CreateClient() used (not manual ContentstackClient construction) +- [ ] LogArrange/LogAct/LogAssert used in correct order +- [ ] TestAssert.* used (not raw Assert.*) +- [ ] [Fact(DisplayName = "FeatureArea - Component Description")] present +- [ ] Happy path test (valid params → expected response) +- [ ] Error path test (invalid params or not found → expected exception) +``` + +### Multi-Targeting Checklist + +```markdown +## Multi-Targeting Review +- [ ] No HttpClient (netstandard2.0 HttpClient has behavioural differences from net4x) +- [ ] No System.Text.Json (not available without separate package in netstandard2.0) +- [ ] No record types (C# 9, requires LangVersion setting for net47/net472) +- [ ] No default interface implementations (C# 8, may affect net47) +- [ ] No nullable reference types without #nullable enable guard +- [ ] No top-level statements (not applicable to library projects but worth checking) +- [ ] Tested compile against netstandard2.0 target (or verified via CI) +``` + +### Plugin Lifecycle Checklist + +```markdown +## Plugin Lifecycle Review +- [ ] New feature that makes HTTP calls uses HttpRequestHandler (plugins run automatically) +- [ ] No WebRequest.Create() called directly in new model classes +- [ ] IContentstackPlugin interface not modified (breaking for all plugin consumers) +- [ ] RequestLoggingPlugin still works with any new request/response changes +- [ ] Plugin.OnRequest receives HttpWebRequest before send +- [ ] Plugin.OnResponse receives response string (can mutate/inspect) +``` + +### Internal Visibility Checklist + +```markdown +## Internal Visibility Review +- [ ] New utility/helper classes in Internals/ are marked `internal` +- [ ] New model types intended for consumers are in Models/ and `public` +- [ ] New configuration types are in Configuration/ and `public` +- [ ] No public exposure of Config, HttpRequestHandler, or VersionUtility +- [ ] InternalsVisibleTo not modified (already covers both test projects) +- [ ] New internal methods accessible in unit tests without changes +``` + +### Common Issues Found in Past PRs + +#### Silent Deserialization Failures +`[JsonProperty]` omitted → field is always null at runtime, no exception. Verify all properties that map CDA JSON fields. + +#### Exception Message in Throw +```csharp +// Bad +throw new QueryFilterException("Please provide valid params."); + +// Good +throw QueryFilterException.Create(innerEx); +// or +throw new QueryFilterException(ErrorMessages.QueryFilterError); +``` + +#### Hardcoded Environment +```csharp +// Bad — breaks for consumers with different environments +mainJson["environment"] = "production"; + +// Correct — already done in Exec() +mainJson["environment"] = ContentTypeInstance.StackInstance.Config.Environment; +``` + +#### Returning void from Query Method +```csharp +// Bad — breaks fluent chaining +public void SetMyParam(string value) { UrlQueries["my_param"] = value; } + +// Good +public Query SetMyParam(string value) { UrlQueries["my_param"] = value; return this; } +``` + +#### Dictionary Not Initialized for QueryValueJson Entry +```csharp +// Bad — throws KeyNotFoundException or InvalidCastException +((Dictionary)QueryValueJson[key])["$op"] = value; + +// Good — guard with ContainsKey +if (!QueryValueJson.ContainsKey(key)) + QueryValueJson[key] = new Dictionary(); +((Dictionary)QueryValueJson[key])["$op"] = value; +``` + +## SDK-specific red flags + +Quick scan for anti-patterns in PRs: + +``` +❌ new HttpClient() — use HttpRequestHandler +❌ throw new Exception("message") — use typed ContentstackException subclass +❌ "hardcoded_field_name" — use [JsonProperty] or ErrorMessages constant +❌ public Config GetConfig() — Config is internal by design +❌ return void — Query methods return Query (fluent) +❌ [JsonProperty] omitted — CDA uses snake_case; PascalCase won't deserialize +❌ in .csproj — use Directory.Build.props +``` + +For full category-by-category review, see **Code review checklist** above. diff --git a/skills/dev-workflow/SKILL.md b/skills/dev-workflow/SKILL.md new file mode 100644 index 0000000..06f5ac7 --- /dev/null +++ b/skills/dev-workflow/SKILL.md @@ -0,0 +1,90 @@ +--- +name: dev-workflow +description: Local development and CI workflow for the Contentstack .NET SDK — solution layout, dotnet build/test commands, package versioning in Directory.Build.props, GitHub Actions (unit tests, CodeQL, NuGet), and integration test credentials. Use when onboarding, running builds, preparing a PR, or finding where CI is defined. +--- + +# Dev workflow – Contentstack .NET SDK + +## When to use + +- Onboarding, building, or running tests locally. +- Finding which workflow applies to PRs, releases, or security scans. +- Maintainer tasks: DocFX, NuGet publish expectations. + +## Invariants + +- Version in **`Directory.Build.props`** only for package version. +- Integration tests need **`app.config`** (or equivalent) — do not commit secrets. + +## Repo tooling — CI, release, docs, security + +### Local commands + +| Action | Command | +|--------|---------| +| Build | `dotnet build Contentstack.Net.sln` | +| Unit tests | `dotnet test Contentstack.Core.Unit.Tests/Contentstack.Core.Unit.Tests.csproj` | +| Integration tests | `dotnet test Contentstack.Core.Tests/Contentstack.Core.Tests.csproj` (credentials) | + +Package version: [`Directory.Build.props`](../../../Directory.Build.props) (``). + +### CI — unit tests + +[`.github/workflows/unit-test.yml`](../../../.github/workflows/unit-test.yml): on `pull_request` and `push`, Windows runner, .NET 7, `dotnet restore` → `dotnet build Contentstack.Net.sln` → `dotnet test` on `Contentstack.Core.Unit.Tests`. + +### CI — branch policy + +[`.github/workflows/check-branch.yml`](../../../.github/workflows/check-branch.yml): on `pull_request`, if base is `master` and head is not `staging`, the job fails with a message to open PRs from `staging` toward `master` per team policy. + +### CI — CodeQL + +[`.github/workflows/codeql-analysis.yml`](../../../.github/workflows/codeql-analysis.yml): static analysis on PRs (language matrix includes C# as configured in the workflow). + +### Release — NuGet + +[`.github/workflows/nuget-publish.yml`](../../../.github/workflows/nuget-publish.yml): triggered on **`release` `created`**. + +- `dotnet pack -c Release -o out` +- Push `contentstack.csharp.*.nupkg` to NuGet.org (`NUGET_API_KEY` / `NUGET_AUTH_TOKEN` secrets — names appear in workflow; values are org secrets). +- Secondary job may push to GitHub Packages (`nuget.pkg.github.com`). + +Maintainers: ensure `Directory.Build.props` version matches the release tag policy before publishing. + +### DocFX API docs + +Configuration: [`docfx_project/docfx.json`](../../../docfx_project/docfx.json). Output site: `_site/` under the docfx project. + +Prerequisite: [DocFX](https://dotnet.github.io/docfx/) CLI installed. + +```bash +cd docfx_project +docfx docfx.json +# or: docfx build docfx.json +``` + +If metadata `src` paths do not resolve (e.g. `src/**.csproj`), adjust `docfx.json` or project layout so metadata generation matches this repository’s structure. + +Related: [`filterRules.yml`](../../../docfx_project/filterRules.yml) for API filter rules. + +### Security / policy automation + +**SCA (dependencies):** [`.github/workflows/sca-scan.yml`](../../../.github/workflows/sca-scan.yml) — on PR, `dotnet restore`, then Snyk Dotnet action against `Contentstack.Core/obj/project.assets.json` (`SNYK_TOKEN` secret). Failures indicate vulnerable packages or scan misconfiguration. + +**Policy (repository hygiene):** [`.github/workflows/policy-scan.yml`](../../../.github/workflows/policy-scan.yml) — for public repos, checks presence of `SECURITY.md` (or `.github/SECURITY.md`) and a license file. Adjust repo contents if these jobs fail. + +When a PR fails these jobs, inspect the workflow log and fix dependencies or policy items as required by your team. + +### Projects (layout) + +| Path | Role | +|------|------| +| `Contentstack.Net.sln` | Main solution | +| `Contentstack.Core/` | Delivery SDK package | +| `Contentstack.AspNetCore/` | DI package | +| `Contentstack.Core.Unit.Tests/` | Unit tests | +| `Contentstack.Core.Tests/` | Integration tests | + +## Related skills + +- [SDK core patterns](../sdk-core-patterns/SKILL.md) — architecture and HTTP. +- [Testing](../testing/SKILL.md) — unit vs integration patterns and credentials. diff --git a/skills/error-handling/SKILL.md b/skills/error-handling/SKILL.md new file mode 100644 index 0000000..b4b08a5 --- /dev/null +++ b/skills/error-handling/SKILL.md @@ -0,0 +1,298 @@ +--- +name: error-handling +description: Error handling patterns for the Contentstack .NET SDK — ContentstackException hierarchy, domain-specific typed exceptions with static factory methods, GetContentstackError WebException parsing, centralized ErrorMessages.cs strings, and consumer catch order. Use when adding new exception types for a new domain area, modifying error messages, handling API HTTP errors, debugging WebException responses, or catching SDK errors in consumer code. +--- + +# Error Handling + +## When to use + +- New exceptions, new `ErrorMessages` strings, or HTTP error parsing. +- Reviewing catches in SDK internals or consumer apps. + +## Invariants + +- Throw **domain-specific** subclasses of `ContentstackException`, not raw `ContentstackException` for domain cases. +- **All user-facing message strings** live in **`ErrorMessages.cs`** — no inline strings in `throw`. +- On HTTP errors, use **`GetContentstackError`** and preserve **`ErrorCode`**, **`StatusCode`**, **`Errors`** when re-throwing. + +## Exception hierarchy (summary) + +`ContentstackException` → `QueryFilterException`, `AssetException`, `LivePreviewException`, `GlobalFieldException`, `EntryException`, `TaxonomyException`, `ContentTypeException` (see `Contentstack.Core/Internals/ContentstackExceptions.cs`). + +Base properties: `ErrorCode`, `StatusCode`, `Errors`. + +## Error Patterns Reference + +### Complete ErrorMessages Catalogue + +All strings in `Contentstack.Core/Internals/ErrorMessages.cs`: + +#### Query and Filter +```csharp +QueryFilterError = "Please provide valid params." +InvalidParamsError = "Invalid parameters provided. {0}" +``` + +#### Asset +```csharp +AssetJsonConversionError = "Failed to convert asset JSON. Please check the asset format and data integrity." +AssetProcessingError = "An error occurred while processing the asset. {0}" +AssetLibraryRequestError = "Exception in {0}: {1}\nStackTrace: {2}" +``` + +#### Entry +```csharp +EntryProcessingError = "An error occurred while processing the entry. {0}" +EntryUidRequired = "Please set entry uid." +EntryNotFoundInCache = "Entry is not present in cache" +``` + +#### Global Field +```csharp +GlobalFieldIdNullError = "GlobalFieldId required. This value cannot be null or empty, define it in your configuration." +GlobalFieldProcessingError = "An error occurred while processing the globalField. {0}" +GlobalFieldQueryError = "Global field query failed. Check your query syntax and field schema before retrying." +``` + +#### Live Preview +```csharp +LivePreviewTokenMissing = "Live Preview token missing. Add either a PreviewToken or a ManagementToken in the LivePreviewConfig." +``` + +#### Client Request +```csharp +ContentstackClientRequestError = "Contentstack client request failed. Check your network settings or request parameters and try again: {0}" +ContentstackSyncRequestError = "An error occurred while processing the Contentstack client request: {0}" +``` + +#### Taxonomy +```csharp +TaxonomyProcessingError = "An error occurred while processing the taxonomy operation: {0}" +``` + +#### Content Type +```csharp +ContentTypeProcessingError = "Content type processing failed. Verify the schema and ensure all required fields are configured." +``` + +#### Authentication and Configuration +```csharp +StackApiKeyRequired = "Stack api key can not be null." +AccessTokenRequired = "Access token can not be null." +EnvironmentRequired = "Environment can not be null." +AuthenticationNotPresent = "Authentication Not present." +ContentTypeNameRequired = "Please set contentType name." +``` + +#### JSON and Parsing +```csharp +InvalidJsonFormat = "Please provide valid JSON." +ParsingError = "Parsing Error." +``` + +#### Network and Server +```csharp +NoConnectionError = "Connection error" +ServerError = "Server interaction went wrong, Please try again." +NetworkUnavailable = "Network not available." +DefaultError = "Oops! Something went wrong. Please try again." +``` + +#### Cache +```csharp +SavingNetworkCallResponseForCache = "Error while saving network call response." +``` + +#### Initialization +```csharp +ContentstackDefaultMethodNotCalled = "You must called Contentstack.stack() first" +``` + +### CDA API Error Response Format + +The Contentstack CDA returns errors in this JSON format: + +```json +{ + "error_message": "The requested entry doesn't exist.", + "error_code": 141, + "errors": { + "field_name": ["validation message"] + } +} +``` + +`error_code` is Contentstack-specific (not HTTP status). Common codes: +- `141` — Entry not found +- `141` — Asset not found +- `109` — API key invalid +- `103` — Access token invalid +- `129` — Invalid query parameters + +### HTTP Status to Exception Mapping + +| HTTP Status | Typical cause | SDK behavior | +|------------|--------------|-------------| +| 400 | Invalid query params | `QueryFilterException` | +| 401 | Invalid API key / token | `ContentstackException` with StatusCode 401 | +| 404 | Entry / asset not found | `ContentstackException` with StatusCode 404 | +| 422 | Invalid field value | `ContentstackException` with `Errors` dict populated | +| 429 | Rate limit exceeded | `ContentstackException` with StatusCode 429 | +| 500 | Server error | `ContentstackException` with StatusCode 500 | + +### Where GetContentstackError Is Called + +The same static `GetContentstackError` method is replicated (acknowledged code smell) in: +- `Query` — wraps in `QueryFilterException` +- `Entry` — wraps in `EntryException` +- `ContentType` — wraps in `ContentTypeException` +- `Asset` — wraps in `AssetException` +- `AssetLibrary` — wraps in `AssetException` + +When adding a new model, follow the same pattern — copy `GetContentstackError` into the new class (or call the one from `Query` if same namespace/access level permits). + +### Exception with Errors Dictionary + +When the API returns field-level validation errors: + +```csharp +catch (ContentstackException ex) +{ + if (ex.Errors != null) + { + foreach (var field in ex.Errors) + { + // field.Key = field name, field.Value = error messages + Console.WriteLine($"Field '{field.Key}': {field.Value}"); + } + } +} +``` + +### LivePreviewException Trigger Conditions + +Thrown when `LivePreviewConfig.Enable = true` but neither `ManagementToken` nor `PreviewToken` is configured: + +```csharp +if (livePreviewConfig.Enable) +{ + if (string.IsNullOrEmpty(livePreviewConfig.ManagementToken) + && string.IsNullOrEmpty(livePreviewConfig.PreviewToken)) + throw new LivePreviewException(); // uses LivePreviewTokenMissing message +} +``` + +### GlobalFieldException.CreateForIdNull Trigger + +Thrown when `GlobalField(null)` or `GlobalField("")` is called — validated before any HTTP request: + +```csharp +public GlobalField GlobalField(string uid) +{ + if (string.IsNullOrEmpty(uid)) + throw GlobalFieldException.CreateForIdNull(); + // ... +} +``` + +### Adding Messages for New Features — Checklist + +1. Add `public const string` to the appropriate section in `ErrorMessages.cs` +2. Use `{0}` placeholder for `string.Format` when appending exception details +3. Add the factory method on the exception class using `ErrorMessages.FormatExceptionDetails(ex)` for processing errors +4. Never concatenate exception details manually — always use `FormatExceptionDetails()` + +--- + +### Static factory pattern (examples) + +Use factories — not `new XyzException("literal")`: + +```csharp +throw QueryFilterException.Create(innerException); +throw GlobalFieldException.CreateForIdNull(); +throw AssetException.CreateForJsonConversionError(); +throw AssetException.CreateForProcessingError(innerException); +throw EntryException.CreateForProcessingError(innerException); +throw TaxonomyException.CreateForProcessingError(innerException); +throw ContentTypeException.CreateForProcessingError(innerException); +``` + +### Adding a new domain exception + +1. Add the class in `ContentstackExceptions.cs` extending `ContentstackException` with static factories. +2. Add `public const string` entries in `ErrorMessages.cs`; use `{0}` when wrapping `FormatExceptionDetails(innerException)`. + +Example skeleton: + +```csharp +public class MyFeatureException : ContentstackException +{ + public MyFeatureException(string message) : base(message) { } + public MyFeatureException(string message, Exception innerException) : base(message, innerException) { } + + public static MyFeatureException CreateForProcessingError(Exception innerException) + { + return new MyFeatureException( + string.Format(ErrorMessages.MyFeatureProcessingError, + ErrorMessages.FormatExceptionDetails(innerException)), + innerException); + } +} +``` + +### GetContentstackError (implementation sketch) + +`WebException` responses are parsed into `ContentstackException` with `ErrorCode`, `StatusCode`, and `Errors`. The same helper pattern is duplicated on `Query`, `Entry`, `ContentType`, `Asset`, `AssetLibrary` — follow the existing copy when adding a new HTTP-calling model. + +```csharp +internal static ContentstackException GetContentstackError(Exception ex) +{ + var webEx = (WebException)ex; + using var stream = webEx.Response.GetResponseStream(); + string errorMessage = new StreamReader(stream).ReadToEnd(); + JObject data = JObject.Parse(errorMessage); + int errorCode = data["error_code"]?.Value() ?? 0; + HttpStatusCode statusCode = ((HttpWebResponse)webEx.Response).StatusCode; + var errors = data["errors"]?.ToObject>(); + return new ContentstackException(data["error_message"]?.Value()) + { + ErrorCode = errorCode, + StatusCode = statusCode, + Errors = errors + }; +} +``` + +### Standard catch block (SDK internals) + +Preserve `ErrorCode`, `StatusCode`, and `Errors` when re-throwing domain exceptions: + +```csharp +catch (Exception ex) +{ + ContentstackException error = GetContentstackError(ex); + throw new QueryFilterException(error.Message, ex) + { + ErrorCode = error.ErrorCode, + StatusCode = error.StatusCode, + Errors = error.Errors + }; +} +``` + +### Consumer catch order (example) + +```csharp +try { /* await query.Find() */ } +catch (QueryFilterException ex) { /* query validation */ } +catch (ContentstackException ex) { /* API / HTTP */ } +catch (Exception ex) { /* network / unknown */ } +``` + +### ErrorMessages.FormatExceptionDetails + +```csharp +ErrorMessages.FormatExceptionDetails(innerException) +``` diff --git a/skills/models-and-serialization/SKILL.md b/skills/models-and-serialization/SKILL.md new file mode 100644 index 0000000..c7a4910 --- /dev/null +++ b/skills/models-and-serialization/SKILL.md @@ -0,0 +1,248 @@ +--- +name: models-and-serialization +description: Model and serialization patterns for the Contentstack .NET SDK — Entry/Asset shape, catch-all Object dictionary, generic Fetch/Find projections, CSJsonConverter attribute-driven converter registration, EntryJsonConverter/AssetJsonConverter, ContentstackCollection, JsonProperty for API name mismatches, entry variants. Use when adding new model types, writing JSON converters, working with entry variants, embedded assets, or modifying serialization settings. +--- + +# Models and Serialization + +## When to use + +- New models, converters, or `JsonProperty` mappings. +- Entry variants, embedded references, RTE/modular blocks behavior. +- Tuning `SerializerSettings`. + +## Invariants + +- CDA JSON is **snake_case** — use **`[JsonProperty("snake_case")]`** on mapped properties; do not rely on default PascalCase naming. +- Custom converters: **`[CSJsonConverter("Name")]`** on the model + **`JsonConverter`** implementation in **`Contentstack.Core/Internals/`**. +- New converters are registered automatically at client init (see **CSJsonConverter registration flow** below). +- Use **`Newtonsoft.Json`** only — not `System.Text.Json`. + +## Serialization Patterns Reference + +### CSJsonConverter Registration Flow + +At `ContentstackClient` construction time: + +``` +1. Scan all assemblies in current AppDomain +2. Find all types with [CSJsonConverter("ConverterName")] attribute +3. Find the JsonConverter class with matching name in Contentstack.Core.Internals +4. Instantiate it and add to SerializerSettings.Converters +5. All subsequent Fetch/Find calls use these converters +``` + +This means **converter registration is automatic** — adding the attribute and converter class is all that's required. + +### EntryJsonConverter Internals + +`EntryJsonConverter` handles the nested entry JSON structure from the CDA: + +```json +{ + "uid": "blt123", + "title": "My Entry", + "publish_details": { "environment": "production", "locale": "en-us", "time": "...", "user": "..." }, + "locale": "en-us", + "_metadata": { ... }, + "custom_field": "value", + "reference_field": [{ "uid": "blt456", "_content_type_uid": "blog" }] +} +``` + +The converter: +1. Reads the raw `JObject` +2. Maps known fields to strongly-typed properties (`Uid`, `Title`, `PublishDetails`, etc.) +3. Puts all remaining fields into `entry.Object` (the catch-all dictionary) + +### AssetJsonConverter Internals + +Similar pattern for assets — maps `uid`, `title`, `url`, `content_type`, `file_size`, `filename`, `tags` to typed properties; remaining fields to `asset.Object`. + +### parseJObject\ in Query + +After `HttpRequestHandler.ProcessRequest` returns a JSON string, `Query.parseJObject` does: + +```csharp +JObject jObject = JObject.Parse(responseString); + +// For entry queries +JArray entries = (JArray)jObject["entries"]; +collection.Items = entries.ToObject>(client.Serializer); +collection.Count = jObject["count"]?.Value() ?? 0; +collection.Skip = (int)UrlQueries.GetValueOrDefault("skip", 0); +collection.Limit = (int)UrlQueries.GetValueOrDefault("limit", 100); +``` + +The `"entries"` token name is fixed by the CDA response format. Asset library uses `"assets"`. + +### JsonProperty Mapping Reference + +Key mappings already in the codebase: + +| JSON field | C# property | Model | +|-----------|-------------|-------| +| `publish_details` | `PublishDetails` | `Entry` | +| `_metadata` | `Metadata` / `_metadata` | `Entry` | +| `content_type` | `ContentType` | `Asset` | +| `file_size` | `FileSize` | `Asset` | +| `filename` | `FileName` | `Asset` | +| `created_at` | `CreatedAt` | various | +| `updated_at` | `UpdatedAt` | various | +| `created_by` | `CreatedBy` | various | + +### Newtonsoft.Json Settings Defaults + +The SDK uses default `JsonSerializerSettings` with no special configuration unless consumers override `client.SerializerSettings`. This means: +- `NullValueHandling.Include` (nulls are included) +- No date format override (ISO 8601 by default) +- No contract resolver override (property names as-is, so `[JsonProperty]` is required) +- No type name handling + +### Handling Embedded RTE Items + +RTE (Rich Text Editor) fields with embedded entries/assets are processed via `contentstack.utils` NuGet package. The `Entry` model surfaces the raw RTE JSON; consumers call the utils library to resolve embedded references: + +```csharp +// RTE field value from entry.Object["rte_field"] +// Pass to contentstack.utils for resolution +Utils.RenderContent(content, entryEmbeds); +``` + +### Deep Reference Deserialization + +When `IncludeReference()` is called, the CDA returns nested objects inside the entry JSON. These are deserialized as nested dictionaries in `entry.Object` or as typed sub-objects when the consumer POCO uses `[JsonProperty]` on the reference field: + +```csharp +public class BlogPost +{ + [JsonProperty("author")] + public Author Author { get; set; } // auto-deserialized if CDA returns expanded ref +} +``` + +If the reference is not expanded (not included), it will be an array of `{"uid": "...", "_content_type_uid": "..."}` objects. + +### Modular Blocks Deserialization + +Modular blocks are returned as JSON arrays of objects, each with a `_content_type_uid` discriminator: + +```json +"modular_blocks": [ + { "block_a": { "field1": "value" } }, + { "block_b": { "field2": "value" } } +] +``` + +Map with a `List>` or use a custom converter with a discriminator switch on the first key. + +### ContentstackCollection Parsing Edge Cases + +- `count` field only present when `IncludeCount()` is set on the query +- `entries` array is present even when empty (`[]`), never `null` +- `skip` and `limit` in the response may differ from what was requested if the CDA has its own limits + +--- + +### Entry model shape (quick reference) + +```csharp +// Strongly-typed fields (typical) +entry.Uid; entry.Title; entry.Tags; entry.Metadata; entry.PublishDetails; + +// Catch-all for arbitrary content type fields +entry.Object // Dictionary +``` + +```csharp +var price = entry.Object["price"]; +var color = entry.Object["color"] as string; +``` + +### Fetch and Find with typed POCOs + +Prefer typed models over `entry.Object` for structured access: + +```csharp +public class Product +{ + [JsonProperty("title")] + public string Title { get; set; } + + [JsonProperty("price")] + public decimal Price { get; set; } + + [JsonProperty("uid")] + public string Uid { get; set; } +} + +var result = await client.ContentType("product").Query().Find(); +var product = await client.ContentType("product").Entry("uid").Fetch(); +``` + +Deserialization uses `client.Serializer` (from `client.SerializerSettings`). + +### JsonProperty — always map snake_case + +The CDA uses `snake_case`. Newtonsoft defaults to PascalCase property names. Always annotate: + +```csharp +[JsonProperty("publish_details")] +public object PublishDetails { get; set; } +``` + +Without `[JsonProperty]`, deserialization looks for the wrong JSON keys. + +### CSJsonConverter on models + +```csharp +[CSJsonConverter("EntryJsonConverter")] +public class Entry { ... } +``` + +Converters live in `Contentstack.Core/Internals/`. See [CSJsonConverter Registration Flow](#csjsonconverter-registration-flow). + +### ContentstackCollection shape + +```csharp +public class ContentstackCollection +{ + public IEnumerable Items { get; } + public int Count { get; } + public int Skip { get; } + public int Limit { get; } +} +``` + +Parsed from `"entries"` or `"assets"` in `Query.parseJObject` (see [parseJObject\ in Query](#parsejobjectt-in-query)). + +### Asset model (common properties) + +`Uid`, `Title`, `Url`, `ContentType`, `FileSize`, `FileName`, `Tags`, plus `Object` for other fields. `AssetJsonConverter` handles nested JSON; `AssetLibrary.FetchAll()` returns `ContentstackCollection`. + +### Entry variants + +```csharp +entry.SetVariant("variant_uid"); +var result = await entry.Fetch(); +``` + +Uses `_variant` and the `x-cs-variant-uid` header path as implemented on `Entry`. + +### Adding a new model type (checklist) + +1. Add class under `Contentstack.Core/Models/` with `[JsonProperty]` on API fields and optional `Object` catch-all. +2. If custom deserialization is required, add `internal class MyModelJsonConverter : JsonConverter` in `Internals/`, implement `ReadJson` / `WriteJson` as needed (delivery is typically read-heavy). +3. Mark the model with `[CSJsonConverter("MyModelJsonConverter")]`. +4. Expose via `ContentstackClient` factory methods if it is a first-class API surface. + +### SerializerSettings customization + +Consumers may adjust before calls: + +```csharp +client.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; +client.SerializerSettings.DateFormatString = "yyyy-MM-dd"; +``` + +`Serializer` is rebuilt from `SerializerSettings` when needed. diff --git a/skills/query-building/SKILL.md b/skills/query-building/SKILL.md new file mode 100644 index 0000000..fa9bbd8 --- /dev/null +++ b/skills/query-building/SKILL.md @@ -0,0 +1,312 @@ +--- +name: query-building +description: Query building patterns for the Contentstack .NET SDK — two-dictionary architecture (QueryValueJson + UrlQueries), Mongo-style operators, fluent chaining, Exec() merge order, pagination (skip/limit), sync API pagination tokens, taxonomy query path. Use when adding query operators or filters, modifying Query.cs, working on pagination, debugging query parameter serialization, or building entry/asset queries. +--- + +# Query Building + +## When to use + +- Adding or changing operators on `Query`, `AssetLibrary`, or related fluent APIs. +- Debugging why a filter or URL param is wrong. +- Pagination, sync, or taxonomy entry queries. + +## Invariants + +- Field-level Mongo filters live in **`QueryValueJson`**; top-level API params (locale, skip, limit, includes, etc.) live in **`UrlQueries`**. +- **`Exec()`** merges `environment`, optional live-preview keys, then `query` (from `QueryValueJson`), then each `UrlQueries` entry—later keys override earlier ones. +- New filter methods return **`this`** and validate keys with **`QueryFilterException`** where appropriate. +- Never set **`environment`** manually in `UrlQueries`—`Exec()` injects it from `Config`. + +## Exec() merge order (compact) + +```csharp +mainJson["environment"] = stack.Config.Environment; +// live preview keys if enabled +if (QueryValueJson.Count > 0) + mainJson["query"] = QueryValueJson; +foreach (var kvp in UrlQueries) + mainJson[kvp.Key] = kvp.Value; +// → HttpRequestHandler.ProcessRequest(..., BodyJson) +``` + +## Query Patterns Reference + +### Quick reference: QueryValueJson operators + +| Method | Operator | Example JSON shape | +|--------|----------|-------------------| +| `Where(key, value)` | direct equality | `{"field": "value"}` | +| `NotEqualTo(key, value)` | `$ne` | `{"field":{"$ne":"x"}}` | +| `ContainedIn(key, values)` | `$in` | `{"field":{"$in":["a","b"]}}` | +| `NotContainedIn(key, values)` | `$nin` | `{"field":{"$nin":["a"]}}` | +| `Exists(key)` | `$exists: true` | `{"field":{"$exists":true}}` | +| `NotExists(key)` | `$exists: false` | `{"field":{"$exists":false}}` | +| `GreaterThan(key, value)` | `$gt` | `{"field":{"$gt":10}}` | +| `LessThan(key, value)` | `$lt` | `{"field":{"$lt":10}}` | +| `Regex(key, pattern)` | `$regex` | `{"field":{"$regex":"^blt"}}` | +| `And(queries[])` | `$and` | array of query objects | +| `Or(queries[])` | `$or` | array of query objects | + +### Quick reference: UrlQueries keys + +| Method | Key | Notes | +|--------|-----|-------| +| `SetLocale(locale)` | `locale` | prefer over obsolete `Language` enum | +| `Skip(n)` | `skip` | pagination offset | +| `Limit(n)` | `limit` | pagination page size | +| `IncludeSchema()` | `include_schema` | `true` | +| `IncludeCount()` | `include_count` | `true` | +| `Tags(tags[])` | `tags` | `string[]` → repeated param | +| `Only(fields[])` | `only[BASE][]` | field projection | +| `Except(fields[])` | `except[BASE][]` | field exclusion | +| `IncludeReference(key)` | `include[]` | reference expansion | + +### Extending Query.cs (new fluent methods) + +#### New Mongo-style filter operator + +```csharp +// Pattern used by all existing operators in Query.cs +public Query MyNewOperator(string key, object value) +{ + if (key == null) + throw QueryFilterException.Create(new ArgumentNullException(nameof(key))); + + if (!QueryValueJson.ContainsKey(key)) + QueryValueJson[key] = new Dictionary(); + + ((Dictionary)QueryValueJson[key])["$myop"] = value; + return this; // always return this for fluent chaining +} +``` + +#### New URL-level parameter + +```csharp +public Query MyParam(string value) +{ + UrlQueries["my_param"] = value; + return this; +} +``` + +### Terminal operations + +```csharp +Task> result = await query.Find(); +Task result = await query.FindOne(); +Task count = await query.Count(); +``` + +### Paginating Find results (entries) + +```csharp +// ContentstackCollection response shape +result.Items // IEnumerable +result.Count // total count (requires IncludeCount()) +result.Skip // current offset +result.Limit // current page size + +// Paging loop +int skip = 0, limit = 100; +ContentstackCollection page; +do { + page = await query.Skip(skip).Limit(limit).Find(); + // process page.Items + skip += limit; +} while (page.Items.Count() == limit); +``` + +### SyncStack short reference + +When using `SyncRecursive` / sync APIs: + +```csharp +SyncStack syncResult = await client.SyncRecursive(parameters); +// SyncStack.PaginationToken — non-null while more pages exist +// SyncStack.SyncToken — final token for next delta sync +``` + +(See [Sync API Patterns](#sync-api-patterns) below for full sync flows.) + +### Complete Operator Categories + +#### Comparison Operators + +```csharp +query.Where("title", "My Entry") // direct equality +query.NotEqualTo("status", "draft") // $ne +query.GreaterThan("price", 100) // $gt +query.GreaterThanEqualTo("price", 100) // $gte +query.LessThan("price", 200) // $lt +query.LessThanEqualTo("price", 200) // $lte +``` + +#### Array / Set Operators + +```csharp +query.ContainedIn("color", new[] {"red", "blue"}) // $in +query.NotContainedIn("color", new[] {"green"}) // $nin +``` + +#### Existence Operators + +```csharp +query.Exists("field_name") // $exists: true +query.NotExists("field_name") // $exists: false +``` + +#### String Operators + +```csharp +query.Regex("uid", "^blt[a-zA-Z0-9]+$") // $regex +query.Regex("title", "^hello", "i") // $regex with $options modifier +``` + +#### Logical Operators + +```csharp +// And / Or take Query[] — each sub-query builds its own QueryValueJson +var q1 = client.ContentType("ct").Query().Where("color", "red"); +var q2 = client.ContentType("ct").Query().Where("size", "large"); +query.And(new[] { q1, q2 }); // $and +query.Or(new[] { q1, q2 }); // $or +``` + +### Reference / Include Patterns + +```csharp +query.IncludeReference("reference_field"); // expand single reference +query.IncludeReference(new[] {"ref1", "ref2"}); // expand multiple +query.IncludeSchema(); // include content type schema +query.IncludeCount(); // include total count in response +query.IncludeOwner(); // include entry owner info +query.IncludeMetadata(); // include entry metadata +``` + +### Field Projection + +```csharp +query.Only(new[] {"title", "uid", "price"}); // return only these fields +query.Except(new[] {"body", "image"}); // exclude these fields + +// For referenced fields +query.OnlyWithReferenceUid(new[] {"title"}, "reference_field"); +query.ExceptWithReferenceUid(new[] {"body"}, "reference_field"); +``` + +### Ordering + +```csharp +query.OrderByAscending("title"); +query.OrderByDescending("created_at"); +``` + +### Locale and Environment + +```csharp +query.SetLocale("en-us"); // preferred — string locale code +// Environment is injected automatically from Config in Exec() +// Never set "environment" manually in UrlQueries +``` + +### Exec() Implementation Detail + +The full merge performed in `Query.Exec()` before calling `HttpRequestHandler`: + +```csharp +var mainJson = new Dictionary(); + +// 1. Environment (always injected from Config) +mainJson["environment"] = ContentTypeInstance.StackInstance.Config.Environment; + +// 2. Live preview headers (if enabled and content type matches) +if (livePreviewActive) +{ + mainJson["live_preview"] = livePreviewConfig.LivePreview; + mainJson["authorization"] = livePreviewConfig.ManagementToken; + // or mainJson["preview_token"] = livePreviewConfig.PreviewToken; +} + +// 3. Mongo-style query filter (only if non-empty) +if (QueryValueJson.Count > 0) + mainJson["query"] = QueryValueJson; + +// 4. All UrlQueries (locale, skip, limit, includes, projections, etc.) +foreach (var kvp in UrlQueries) + mainJson[kvp.Key] = kvp.Value; +``` + +### How QueryValueJson Is Serialized + +`Dictionary` values are JSON-serialized by `HttpRequestHandler`: + +``` +QueryValueJson = { "title": {"$ne": "Draft"}, "color": {"$in": ["red","blue"]} } +→ query={"title":{"$ne":"Draft"},"color":{"$in":["red","blue"]}} +``` + +This becomes a single `query=` URL parameter with the JSON as its value. + +### Sync API Patterns + +```csharp +// Initial sync (all published content) +SyncStack result = await client.Sync(new SyncStack() { Type = SyncType.entry_published }); + +// Paginated initial sync +SyncStack result = await client.SyncRecursive(parameters); +// result.PaginationToken — continue paginating if not null +// result.SyncToken — use for next delta sync + +// Delta sync (changes since last sync) +SyncStack delta = await client.SyncToken(result.SyncToken); + +// Manual pagination loop +SyncStack page = initialResult; +while (page.PaginationToken != null) +{ + page = await client.SyncPaginationToken(page.PaginationToken); + // process page.Items +} +``` + +### Taxonomy Query Patterns + +```csharp +// Create taxonomy query via client.Taxonomies() +Query taxonomyQuery = client.Taxonomies(); + +// Same filter methods apply +taxonomyQuery.Where("taxonomies.animals", new Dictionary { + { "$eq", "mammals" } +}); + +var results = await taxonomyQuery.Find(); +``` + +**URL paths** + +- Normal content-type query: `{stack.Config.BaseUrl}/content_types/{uid}/entries` +- Taxonomy query (`client.Taxonomies()`): `{stack.Config.BaseUrl}/taxonomies/entries` + +Filter and pagination APIs are the same; only the base path differs. + +### AssetLibrary Query Patterns + +```csharp +AssetLibrary assetLib = client.Assets(); +assetLib.Skip(0).Limit(100); +assetLib.IncludeCount(); +assetLib.SetLocale("en-us"); +ContentstackCollection assets = await assetLib.FetchAll(); +``` + +### Common Mistakes to Avoid + +- Never set `"environment"` key manually in `UrlQueries` — it is always injected from `Config.Environment` in `Exec()` +- Never call `ProcessRequest` directly — always go through model methods (`Find`, `Fetch`, etc.) +- Never modify `QueryValueJson` from outside `Query` — use the public fluent methods +- `And()` / `Or()` take full `Query` instances, not raw dictionaries +- `string[]` values in `UrlQueries` become repeated URL params, not JSON arrays — use for tags, not Mongo operators diff --git a/skills/sdk-core-patterns/SKILL.md b/skills/sdk-core-patterns/SKILL.md new file mode 100644 index 0000000..b6e36e3 --- /dev/null +++ b/skills/sdk-core-patterns/SKILL.md @@ -0,0 +1,241 @@ +--- +name: sdk-core-patterns +description: Core architecture of the Contentstack .NET Delivery SDK — namespaces, ContentstackClient factory, Config/ContentstackOptions, HttpRequestHandler (HttpWebRequest GET), plugin hooks, ASP.NET Core DI, and multi-targeting. Use when working on new SDK features, adding model types, wiring DI, understanding request flow, or onboarding to the codebase. +--- + +# SDK Core Patterns + +## When to use + +- Onboarding, request flow, or where types live. +- Changing HTTP behavior, config, plugins, or DI. +- Anything that must go through `HttpRequestHandler`. + +## Namespace map + +| Namespace | Purpose | +|-----------|---------| +| `Contentstack.Core` | `ContentstackClient` — root entry point | +| `Contentstack.Core.Models` | `Query`, `Entry`, `Asset`, `AssetLibrary`, `ContentType`, `GlobalField`, `GlobalFieldQuery`, `Taxonomy`, `SyncStack`, `ContentstackCollection` | +| `Contentstack.Core.Configuration` | `ContentstackOptions`, `Config` (internal), `LivePreviewConfig` | +| `Contentstack.Core.Internals` | `HttpRequestHandler`, exceptions, enums, converters, constants — all `internal` | +| `Contentstack.Core.Interfaces` | `IContentstackPlugin` | +| `Contentstack.AspNetCore` | `IServiceCollectionExtensions` for DI registration | + +## Invariants + +- **`ContentstackClient`** is the only public entry point for creating stack operations. +- **All HTTP** goes through **`HttpRequestHandler.ProcessRequest`** — no `HttpClient`, no bypassing for feature work. +- **GET + query string** for requests; plugins run `OnRequest` / `OnResponse` around the call. +- **Multi-target:** `netstandard2.0`, `net47`, `net472` — no `System.Text.Json`; Newtonsoft.Json throughout. +- **Version:** `Directory.Build.props` only — do not set `` in individual `.csproj` files. + +## ContentstackClient (compact) + +```csharp +var client = new ContentstackClient(options); + +client.ContentType("uid").Query().Find(); +client.ContentType("uid").Entry("uid"); +client.Assets(); client.Asset("uid"); +client.GlobalField("uid"); client.GlobalFields(); +client.Taxonomies(); client.Sync(...); +``` + +## Options → Config → BaseUrl + +`ContentstackOptions` → internal `Config` → `BaseUrl` (region + host + version). Required: `ApiKey`, `DeliveryToken`, `Environment`. Region table, live preview host switching, and internal client state are in **SDK architecture reference** below. ASP.NET Core registration is covered under **ASP.NET Core integration** below. + + +## SDK Architecture Reference + +### Full Request Flow + +``` +ContentstackClient + └── ContentType("uid") → ContentType + └── Query() → Query + └── Find() → Query.Exec() + └── HttpRequestHandler.ProcessRequest(url, headers, bodyJson) + ├── Serialize BodyJson → query string + ├── Create HttpWebRequest (GET) + ├── Set headers (api_key, access_token, branch, x-user-agent) + ├── foreach plugin: OnRequest(client, request) + ├── await request.GetResponseAsync() + ├── foreach plugin: OnResponse(client, request, response, body) + └── return JSON string → parsed in Query.parseJObject +``` + +### Config.BaseUrl Composition + +``` +Protocol Region Code Host Version +"https://" "" "cdn.contentstack.io" "/v3" → US (default) +"https://" "eu-" "cdn.contentstack.com" "/v3" → EU +"https://" "azure-na-" "cdn.contentstack.com" "/v3" → AZURE_NA +"https://" "azure-eu-" "cdn.contentstack.com" "/v3" → AZURE_EU +"https://" "gcp-na-" "cdn.contentstack.com" "/v3" → GCP_NA +"https://" "au-" "cdn.contentstack.com" "/v3" → AU +``` + +`HostURL` defaults to `cdn.contentstack.io` for US, `cdn.contentstack.com` for all other regions. + +### Live Preview URL Resolution + +When `LivePreviewConfig.Enable == true` and `LivePreview != "init"` and `ContentTypeUID` matches the queried content type, `Config.getBaseUrl()` returns the live preview host instead of `BaseUrl`: + +``` +"https://{livePreviewConfig.Host}/{version}" +``` + +Additional headers injected: `live_preview`, `authorization` (management token) or `preview_token`, optional `release_id`, `preview_timestamp`. + +### Query String Serialization Rules (HttpRequestHandler) + +| Value type | Serialization | +|-----------|--------------| +| `string` | `key=value` | +| `string[]` | `key=v1&key=v2` (repeated) | +| `Dictionary` | `key={"$in":["a","b"]}` (JSON) | +| Other | `key=value.ToString()` | + +### ContentstackClient Internal State + +```csharp +internal string StackApiKey // from options +internal Dictionary _Headers // api_key, access_token/delivery_token +internal Dictionary _StackHeaders // shared across requests +internal LivePreviewConfig LivePreviewConfig // null if not configured +public List Plugins // empty by default +public JsonSerializerSettings SerializerSettings // for Fetch/Find +internal JsonSerializer Serializer // created from SerializerSettings +``` + +### How Models Get Stack Context + +All model constructors are `internal`. `ContentstackClient` methods set back-references: + +```csharp +// ContentType.cs internal wiring +internal ContentstackClient StackInstance { get; set; } + +// Query.cs +private ContentType ContentTypeInstance { get; set; } // for entry path +private ContentstackClient TaxonomyInstance { get; set; } // for taxonomy path +``` + +Models build their URL from `ContentTypeInstance.StackInstance.Config.BaseUrl` at call time (lazy). + +### Plugin Implementation Pattern + +```csharp +public class MyPlugin : IContentstackPlugin +{ + public Task OnRequest(ContentstackClient stack, HttpWebRequest request) + { + // Mutate request (add headers, log, etc.) + return Task.FromResult(request); + } + + public Task OnResponse(ContentstackClient stack, HttpWebRequest request, + HttpWebResponse response, string responseString) + { + // Inspect/transform response body string + return Task.FromResult(responseString); + } +} + +// Register +client.Plugins.Add(new MyPlugin()); +``` + +### ContentstackRegion Enum Values + +```csharp +public enum ContentstackRegion { US, EU, AZURE_NA, AZURE_EU, GCP_NA, AU } +``` + +`ContentstackRegionCode` (internal enum) maps to URL prefixes: `eu`, `azure_na`, `azure_eu`, `gcp_na`, `au`. Underscores are replaced with hyphens in the URL. + +### Key NuGet Dependencies (Contentstack.Core.csproj) + +| Package | Version | Purpose | +|---------|---------|---------| +| `Newtonsoft.Json` | 13.0.4 | All JSON serialization | +| `Microsoft.Extensions.Options` | 8.0.2 | `IOptions` | +| `Markdig` | 0.36.2 | Markdown processing in RTE fields | +| `contentstack.utils` | 1.0.6 | RTE embedded item resolution | + +### Solution Layout + +``` +Contentstack.Net.sln +├── Contentstack.Core/ ← Main SDK package (contentstack.csharp on NuGet) +├── Contentstack.AspNetCore/ ← DI extension (contentstack.aspnetcore on NuGet) +├── Contentstack.Core.Tests/ ← Integration tests (net7.0, hits live API) +└── Contentstack.Core.Unit.Tests/ ← Unit tests (no network) +``` + +Version shared via `Directory.Build.props` → `2.26.0` (or current). + +### Supporting internals (maintainers) + +When debugging HTTP, serialization edges, or multi-target behavior, these types in `Contentstack.Core/Internals/` are often involved. They are not public API. + +| Area | Types / files | +|------|----------------| +| Async helpers around `HttpWebRequest` | `WebRequestAsyncExtensions.cs` | +| Version string / user-agent composition | `VersionUtility.cs`, `StackOutput.cs` | +| JSON / value coercion helpers | `ContentstackConvert.cs` | +| Language / locale enums | `LanguageEnums.cs` | + +Prefer changing behavior through `HttpRequestHandler`, `Config`, and public models rather than exposing these types. + +## ASP.NET Core integration + +Source: [`Contentstack.AspNetCore/IServiceCollectionExtensions.cs`](../../../Contentstack.AspNetCore/IServiceCollectionExtensions.cs). + +### Registration + +Two overloads register the same services: + +```csharp +public static IServiceCollection AddContentstack(this IServiceCollection services, IConfigurationRoot configuration) +public static IServiceCollection AddContentstack(this IServiceCollection services, IConfiguration configuration) +``` + +Both: + +1. `services.AddOptions()` +2. `services.Configure(configuration.GetSection("ContentstackOptions"))` +3. `services.TryAddTransient()` + +### Configuration section + +Bind options from configuration using section name **`ContentstackOptions`**: + +```json +{ + "ContentstackOptions": { + "ApiKey": "...", + "DeliveryToken": "...", + "Environment": "..." + } +} +``` + +Adjust property names to match [`ContentstackOptions`](../../../Contentstack.Core/Configuration/ContentstackOptions.cs) public properties. + +### Service lifetime + +`ContentstackClient` is registered as **transient** (`TryAddTransient`). Each resolution gets a new instance; use this when injecting into short-lived scopes or when the app expects a fresh client per operation. + +### Usage in app code + +Inject `ContentstackClient` or `IOptions` as needed after calling `AddContentstack` in `Program.cs` / `Startup.cs`: + +```csharp +services.AddContentstack(configuration); +``` + +Ensure `configuration` includes the `ContentstackOptions` section (e.g. `appsettings.json`, environment variables, user secrets). diff --git a/skills/testing/SKILL.md b/skills/testing/SKILL.md new file mode 100644 index 0000000..e76b7bc --- /dev/null +++ b/skills/testing/SKILL.md @@ -0,0 +1,334 @@ +--- +name: testing +description: Testing patterns for the Contentstack .NET SDK — unit tests using AutoFixture and private field reflection (no network), and integration tests using IntegrationTestBase, TestDataHelper, LogArrange/LogAct/LogAssert, xUnit traits, and RequestLoggingPlugin. Use when writing new tests, adding coverage for a new feature, debugging integration test failures, or understanding the test structure. +--- + +# Testing + +## When to use + +- New unit tests for `Query` or models (reflection on `QueryValueJson` / `UrlQueries`). +- New or failing integration tests against the live API. + +## Projects + +| Project | Framework | Purpose | +|---------|-----------|---------| +| `Contentstack.Core.Unit.Tests` | xUnit + AutoFixture | No network; assert internal state via reflection | +| `Contentstack.Core.Tests` | xUnit, net7.0 | Live API; requires `app.config` (or equivalent) credentials | + +## Invariants + +- Unit tests: **no real network** — use AutoFixture for options, reflection for private dictionaries. +- Integration tests: **never commit secrets** — credentials from `app.config` / env locally. +- New `Query` methods: unit test operators + null/invalid cases; integration test when behavior is API-bound. + +## Testing Patterns Reference + +### Complete Unit Test Template + +```csharp +using System.Collections.Generic; +using System.Reflection; +using AutoFixture; +using Contentstack.Core; +using Contentstack.Core.Configuration; +using Contentstack.Core.Models; +using Contentstack.Core.Internals; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Contentstack.Core.Unit.Tests +{ + public class MyFeatureUnitTests + { + private readonly IFixture _fixture = new Fixture(); + private ContentstackClient _client; + + public MyFeatureUnitTests() + { + var options = new ContentstackOptions() + { + ApiKey = _fixture.Create(), + DeliveryToken = _fixture.Create(), + Environment = _fixture.Create() + }; + _client = new ContentstackClient(new OptionsWrapper(options)); + } + + private Query CreateQuery(string contentTypeId = "source") + => _client.ContentType(contentTypeId).Query(); + + // Helper: get private QueryValueJson + private Dictionary GetQueryValueJson(Query query) + { + var field = typeof(Query).GetField("QueryValueJson", + BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.FlattenHierarchy); + return (Dictionary)field?.GetValue(query); + } + + // Helper: get private UrlQueries + private Dictionary GetUrlQueries(Query query) + { + var field = typeof(Query).GetField("UrlQueries", + BindingFlags.NonPublic | BindingFlags.Instance); + return (Dictionary)field?.GetValue(query); + } + + [Fact] + public void MyOperator_AddsCorrectQueryParameter() + { + var query = CreateQuery(); + var key = _fixture.Create(); + + var result = query.MyOperator(key, "value"); + + Assert.Equal(query, result); // fluent return + var qvj = GetQueryValueJson(query); + Assert.True(qvj.ContainsKey(key)); + var inner = qvj[key] as Dictionary; + Assert.True(inner.ContainsKey("$myop")); + Assert.Equal("value", inner["$myop"]); + } + + [Fact] + public void MyUrlParam_AddsToUrlQueries() + { + var query = CreateQuery(); + + query.SetLocale("en-us"); + + var urlQueries = GetUrlQueries(query); + Assert.Equal("en-us", urlQueries["locale"]); + } + + [Fact] + public void MyOperator_WithNullKey_ThrowsQueryFilterException() + { + var query = CreateQuery(); + Assert.Throws(() => query.MyOperator(null, "value")); + } + + [Fact] + public void MyOperator_WithEmptyKey_ThrowsQueryFilterException() + { + var query = CreateQuery(); + Assert.Throws(() => query.MyOperator(string.Empty, "value")); + } + + [Fact] + public void MyOperator_ReturnsQueryForChaining() + { + var query = CreateQuery(); + var result = query + .MyOperator("field1", "value1") + .MyOperator("field2", "value2"); + Assert.Equal(query, result); + } + } +} +``` + +### Complete Integration Test Template + +```csharp +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; +using Contentstack.Core.Models; +using Contentstack.Core.Tests.Helpers; + +namespace Contentstack.Core.Tests.Integration.MyFeatureTests +{ + public class MyFeatureComprehensiveTest : IntegrationTestBase + { + public MyFeatureComprehensiveTest(ITestOutputHelper output) : base(output) + { + } + + [Fact(DisplayName = "MyFeature - BasicOperation ReturnsExpectedResult")] + public async Task MyFeature_BasicOperation_ReturnsExpectedResult() + { + // Arrange + LogArrange("Setting up basic operation test"); + LogContext("ContentType", TestDataHelper.SimpleContentTypeUid); + + var client = CreateClient(); + var query = client.ContentType(TestDataHelper.SimpleContentTypeUid).Query(); + + // Act + LogAct("Executing query with my feature"); + query.MyOperator("uid", "someValue"); + var result = await query.Find(); + + // Assert + LogAssert("Verifying response structure"); + TestAssert.NotNull(result); + TestAssert.NotNull(result.Items); + TestAssert.True(result.Count >= 0, "Count should be non-negative"); + } + + [Fact(DisplayName = "MyFeature - WithInvalidParams ThrowsException")] + public async Task MyFeature_WithInvalidParams_ThrowsException() + { + // Arrange + LogArrange("Setting up error scenario"); + var client = CreateClient(); + + // Act & Assert + LogAct("Executing with invalid parameters"); + await Assert.ThrowsAsync(async () => + { + await client.ContentType("nonexistent_type_12345") + .Query().Find(); + }); + } + } +} +``` + +### Existing Unit Test Files — What Each Covers + +| File | Covers | +|------|--------| +| `QueryUnitTests.cs` | All `Query` filter/operator methods, UrlQueries params | +| `EntryUnitTests.cs` | `Entry` field access, URL construction, header setting | +| `AssetUnitTests.cs` | `Asset` model fields | +| `AssetLibraryUnitTests.cs` | `AssetLibrary` query params, Skip/Limit | +| `ContentstackClientUnitTests.cs` | Client initialization, header injection, factory methods | +| `ContentstackOptionsUnitTests.cs` | Options defaults, validation | +| `ContentstackExceptionUnitTests.cs` | Exception hierarchy, factory methods, message content | +| `ConfigUnitTests.cs` | BaseUrl composition, region codes | +| `ContentstackRegionUnitTests.cs` | Region enum → URL prefix mapping | +| `GlobalFieldUnitTests.cs` | GlobalField ID validation, URL construction | +| `GlobalFieldQueryUnitTests.cs` | GlobalFieldQuery filter methods | +| `TaxonomyUnitTests.cs` | Taxonomy query path | +| `JsonConverterUnitTests.cs` | CSJsonConverter attribute registration | +| `LivePreviewConfigUnitTests.cs` | LivePreviewConfig validation | + +### Existing Integration Test Folders — What Each Covers + +| Folder | Covers | +|--------|--------| +| `QueryTests/` | Query operators, complex filters, field queries, includes | +| `EntryTests/` | Entry fetch, field projection, references | +| `GlobalFieldsTests/` | Global field schemas, nested global fields | +| `SyncTests/` | Sync API, pagination tokens, delta sync | +| `AssetTests/` | Asset fetch, asset library queries | +| `ContentTypeTests/` | Content type fetch, schema queries | +| `LocalizationTests/` | Locale filtering, locale fallback chains | +| `PaginationTests/` | Skip/Limit behavior, count accuracy | +| `ErrorHandling/` | API error codes, exception types, invalid params | +| `LivePreview/` | Live preview URL routing, token headers | +| `ModularBlocksTests/` | Modular block field deserialization | +| `MetadataTests/` | Entry metadata fields | +| `TaxonomyTests/` | Taxonomy query path, taxonomy filtering | +| `VariantsTests/` | Entry variant headers, variant content | +| `BranchTests/` | Branch header injection | + +### MockHttpHandler Pattern (Unit Tests) + +When you need to mock HTTP responses without network: + +```csharp +// In Mokes/MockHttpHandler.cs — extend for new mock scenarios +// MockResponse.cs — add JSON fixture strings for new response shapes +// MockInfrastructureTest.cs — base class wiring MockHttpHandler into client +``` + +### TestAssert Wrappers + +Use `TestAssert.*` instead of raw `Assert.*` in integration tests — they log assertion context to `ITestOutputHelper`: + +```csharp +TestAssert.NotNull(result); +TestAssert.Equal(expected, actual); +TestAssert.True(condition, "failure message"); +TestAssert.False(condition, "failure message"); +TestAssert.IsAssignableFrom>(result.Items); +TestAssert.Matches("^blt[a-zA-Z0-9]+$", entry.Uid); +``` + +### app.config Keys for Integration Tests + +Integration tests read config from `Contentstack.Core.Tests/app.config`: + +```xml + + + + + + + + + + +``` + +Never commit real credentials. Use environment variables or a secrets manager in CI. + +### Running Tests + +```bash +# Unit tests only (no credentials needed) +dotnet test Contentstack.Core.Unit.Tests/ + +# Integration tests (requires app.config with valid credentials) +dotnet test Contentstack.Core.Tests/ + +# Run specific category +dotnet test --filter "Category=RetryIntegration" + +# Run specific test class +dotnet test --filter "FullyQualifiedName~QueryOperatorsComprehensiveTest" +``` + +### Mokes folder (unit tests) + +`Contentstack.Core.Unit.Tests/Mokes/`: + +- `MockHttpHandler.cs` — intercepts HTTP without network +- `MockResponse.cs` — sample JSON response fixtures +- `MockInfrastructureTest.cs` — base for tests needing mock HTTP +- `Utilities.cs` — test utility helpers + +### RequestLoggingPlugin (integration) + +`CreateClient()` on `IntegrationTestBase` adds `RequestLoggingPlugin`, which logs HTTP requests and responses via `ITestOutputHelper`. No extra setup required. + +Custom plugins for a test: + +```csharp +var client = CreateClient(); +client.Plugins.Add(new MyTestPlugin()); +``` + +### Test coverage guidelines + +- Unit test: every new public `Query` method (operator or URL param) +- Unit test: null/invalid input → expected exception type +- Integration test: happy path with real API response +- Integration test: verify response shape (`Items`, `Count`, fields) +- Place integration tests under the folder that matches the feature area + +### Integration test file conventions + +- Folders mirror features: `Integration/QueryTests/`, `Integration/EntryTests/`, `Integration/GlobalFieldsTests/`, etc. +- One test class per broad concern when it makes sense; file names often end in `Test.cs` / `Tests.cs` +- Use `LogArrange` / `LogAct` / `LogAssert` / `LogContext` from `IntegrationTestBase` (see templates above) + +#### xUnit traits (examples) + +```csharp +[Trait("Category", "RetryIntegration")] +[Trait("Category", "LivePreview")] +[Trait("Category", "Sync")] +``` + +#### DisplayName convention + +```csharp +[Fact(DisplayName = "Query Operations - Regex Complex Pattern Matches Correctly")] +// FeatureArea - ComponentAction Outcome +```