diff --git a/.cursor/rules/README.md b/.cursor/rules/README.md new file mode 100644 index 0000000..d107daa --- /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/.github/workflows/check-branch.yml b/.github/workflows/check-branch.yml index 2332f0d..856c718 100644 --- a/.github/workflows/check-branch.yml +++ b/.github/workflows/check-branch.yml @@ -7,14 +7,12 @@ jobs: check_branch: runs-on: ubuntu-latest steps: - - name: Comment PR - if: github.base_ref == 'master' && github.head_ref != 'staging' - uses: thollander/actions-comment-pull-request@v2 - with: - message: | - We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the staging branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch. - - name: Check branch - if: github.base_ref == 'master' && github.head_ref != 'staging' + # PRs into master are no longer blocked here based on their source branch. + # Direct merges into master (i.e. without going through PR review) are expected + # to be prevented by GitHub branch protection rules on master, not by this CI check. + - name: Branch check (informational only) run: | - echo "ERROR: We regret to inform you that you are currently not able to merge your changes into the master branch due to restrictions applied by our SRE team. To proceed with merging your changes, we kindly request that you create a pull request from the staging branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch." - exit 1 + echo "This workflow no longer restricts which branch a PR into master can come from." + echo "Base branch : ${{ github.base_ref }}" + echo "Head branch : ${{ github.head_ref }}" + echo "Direct pushes/merges to master should be prevented via branch protection rules, not this workflow." diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 7b9df7f..f060255 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -5,13 +5,15 @@ on: release: types: [created] jobs: - publish-nuget: + publish-nuget: runs-on: windows-latest steps: - name: Checkout repository - uses: actions/checkout@v1 - - name: Setup .NET Core @ Latest - uses: actions/setup-dotnet@v3 + uses: actions/checkout@v4 + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.x' env: NUGET_AUTH_TOKEN: ${{ secrets.NUGET_API_KEY }} - name: Build solution and generate NuGet package @@ -24,14 +26,15 @@ jobs: cd out dotnet nuget push "contentstack.model.generator.*.nupkg" --api-key ${{ secrets.NUGET_API_KEY }} --skip-duplicate --no-symbols true --source https://api.nuget.org/v3/index.json - publish-git: + publish-git: runs-on: windows-latest steps: - name: Checkout repository - uses: actions/checkout@v1 - - name: Setup .NET Core @ Latest - uses: actions/setup-dotnet@v3 + uses: actions/checkout@v4 + - name: Setup .NET 10 + uses: actions/setup-dotnet@v4 with: + dotnet-version: '10.x' source-url: https://nuget.pkg.github.com/Contentstack/index.json env: NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index f59e822..4224e8f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,7 @@ packages/ */mono_crash* .vscode .dccache -.sonarqube/ \ No newline at end of file +.sonarqube/ +*/TestResults/ +TestResults/ +test-report_*.html \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c00e8b9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,51 @@ +# Contentstack Model Generator – Agent guide + +**Universal entry point** for contributors and AI agents. Detailed conventions live in **`skills/*/SKILL.md`**. + +## What this repo is + +| Field | Detail | +| --- | --- | +| **Name:** | [contentstack/contentstack-model-generator](https://github.com/contentstack/contentstack-model-generator) | +| **Purpose:** | A **.NET global tool** (`contentstack.model.generator`) that connects to the Content Management API (CMA), fetches content types and global fields from a stack, and **emits C# model classes** (and helpers) under a `Models` output folder. | +| **Out of scope (if any):** | This package is a **CLI code generator**, not a runtime Contentstack SDK for apps. Product install and CLI flags are documented in [README.md](README.md). | + +## Tech stack (at a glance) + +| Area | Details | +| --- | --- | +| **Language** | C# — tool targets **.NET 7** (`net7.0`); test project targets **.NET 9** (`net9.0`). | +| **Build** | .NET SDK — solution [contentstack.model.generator/contentstack.model.generator.sln](contentstack.model.generator/contentstack.model.generator.sln); main project [contentstack.model.generator/contentstack.model.generator.csproj](contentstack.model.generator/contentstack.model.generator.csproj) (`PackAsTool`, `PackOnBuild`). | +| **Tests** | xUnit — project [contentstack.model.generator.tests/contentstack.model.generator.tests.csproj](contentstack.model.generator.tests/contentstack.model.generator.tests.csproj); Moq; coverlet.collector. | +| **Lint / coverage** | Built-in .NET analyzers; coverlet for coverage in tests. No repo-wide `dotnet format` / StyleCop config in-tree at time of writing. | +| **Tool dependencies** | [contentstack.model.generator.csproj](contentstack.model.generator/contentstack.model.generator.csproj): McMaster.Extensions.CommandLineUtils, Newtonsoft.Json; HTTP via [HTTPRequestHandler.cs](contentstack.model.generator/CMA/HTTPRequestHandler.cs) (`HttpClient`). | +| **Consumer projects (generated `Models/`)** | Emitted C# references **Contentstack.Core**, **Contentstack.Utils**, **Newtonsoft.Json**, and **Markdig** (e.g. generated `ContentstackStringExtension`). Those NuGet packages must be added to the **application that uses the generated code**, not to the generator tool project. | + +## Commands (quick reference) + +| Command type | Command | +| --- | --- | +| **Restore** | `dotnet restore contentstack.model.generator/contentstack.model.generator.sln` | +| **Build** | `dotnet build contentstack.model.generator/contentstack.model.generator.sln` | +| **Test** | `dotnet test contentstack.model.generator/contentstack.model.generator.sln` | +| **Pack (tool NuGet)** | From [contentstack.model.generator/](contentstack.model.generator/): CI uses `dotnet pack -c Release -o out` ([nuget-publish.yml](.github/workflows/nuget-publish.yml)), producing `.nupkg` under `contentstack.model.generator/out/`. Local builds also use **`PackOnBuild`** and **`PackageOutputPath`** (`./nupkg`), so packages may appear under `contentstack.model.generator/nupkg/` unless you override with `-o`. | +| **Install globally (users)** | See [README.md](README.md) (`dotnet tool install -g contentstack.model.generator`). | + +**CI / automation:** [.github/workflows/](.github/workflows/) — e.g. branch check ([check-branch.yml](.github/workflows/check-branch.yml)), SCA ([sca-scan.yml](.github/workflows/sca-scan.yml)), CodeQL ([codeql-analysis.yml](.github/workflows/codeql-analysis.yml)), NuGet publish on release ([nuget-publish.yml](.github/workflows/nuget-publish.yml)). **No workflow currently runs `dotnet test`** — run tests locally before opening a PR (see [skills/dev-workflow/SKILL.md](skills/dev-workflow/SKILL.md)). + +## Where the documentation lives: skills + +| Skill | Path | What it covers | +| --- | --- | --- | +| **Dev workflow** | [skills/dev-workflow/SKILL.md](skills/dev-workflow/SKILL.md) | Branches, CI, build/test/pack commands, solution paths. | +| **Testing** | [skills/testing/SKILL.md](skills/testing/SKILL.md) | Test layout, xUnit/Moq/coverlet, test file map, secrets/credentials. | +| **Code review** | [skills/code-review/SKILL.md](skills/code-review/SKILL.md) | PR checklist, branch policy, security scans. | +| **C# / .NET** | [skills/csharp-dotnet/SKILL.md](skills/csharp-dotnet/SKILL.md) | TFMs, namespaces, project layout conventions. | +| **Framework (libraries)** | [skills/framework/SKILL.md](skills/framework/SKILL.md) | McMaster CLI, Newtonsoft.Json, HTTP client, CMA `Config` / base URL—third-party and platform glue. | +| **Product (CLI + CMA + codegen)** | [skills/contentstack-model-generator/SKILL.md](skills/contentstack-model-generator/SKILL.md) | End-to-end runtime flow, layers, extension points. | + +Quick links to each **SKILL.md**: [skills/README.md](skills/README.md). + +## Using Cursor (optional) + +If you use **Cursor**, [.cursor/rules/README.md](.cursor/rules/README.md) points to **[AGENTS.md](AGENTS.md)** so editor-specific config does not duplicate the canonical docs. diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d402e..d383155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +### Version: 1.0.0 +#### Date: July-13-2026 + +##### Breaking Changes: +- Removed the `Newtonsoft.Json` dependency entirely. Both the tool's internals and the models/converters it generates now use `System.Text.Json`. +- Generated model files now use `[JsonPropertyName]` instead of `[JsonProperty]`, and reference `System.Text.Json` / `System.Text.Json.Nodes` / `System.Text.Json.Serialization` instead of `Newtonsoft.Json`. +- Generated embedded-object and modular-block converters now inherit `System.Text.Json.Serialization.JsonConverter` (`Read`/`Write`) instead of Newtonsoft's `JsonConverter` (`ReadJson`/`WriteJson`). Regenerating models against an existing project will change these attributes and converters - update consuming code accordingly. +- Requires **.NET 10.0** or later (previously .NET 7.0). +- `ContentstackException` no longer supports legacy `BinaryFormatter`-based serialization (removed the obsolete `SerializationInfo`/`StreamingContext` constructor and `GetObjectData` override). + +##### Enhancements: +- Upgraded target framework from net7.0 to net10.0. +- Updated CI/CD pipeline to use .NET 10 SDK (actions/setup-dotnet@v4). +- Updated Microsoft.AspNetCore.Mvc.Testing from 9.0.9 to 10.0.0. +- Migrated internal model DTOs (Field, MetaData, Contenttype, StackResponse, ContentStackError, OAuth token response) from `[JsonProperty]` to `[JsonPropertyName]`. +- Migrated OAuthService token exchange/refresh deserialization to `System.Text.Json`. +- Migrated the HTTP layer (IResponse, ContentstackResponse, HTTPRequestHandler, ContentstackHttpRequest) from Newtonsoft `JObject` to `System.Text.Json.Nodes.JsonObject`. +- Migrated ContentstackClient's serializer settings, response deserialization, and error parsing to `System.Text.Json` (`JsonSerializerOptions`, `JsonNode`). +- Migrated the code-generation templates (using statements, field attributes, link class, embedded-object converter, modular-block converter, and helper class) to emit `System.Text.Json`-based generated code. +- Fixed reference-field type resolution (`GetDatatypeForContentType`) to use `System.Text.Json.JsonElement` instead of the removed Newtonsoft `JArray`/`JObject` types. + +##### Bug fix: +- Fixed GetHeader method visibility (private → internal) to allow test access. +- Fixed a template bug where the generated `embeddedItems` property's nullable annotation was emitted as literal text (`{nullableString()}`) instead of being applied, for content types with RTE-embedded references. + ### Version: 0.5.1 #### Date: Jan-12-2026 diff --git a/README.md b/README.md index 19aaaf5..8641578 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,20 @@ # Contentstack model generator This utility is use to generate models based on ContentTypes in Stack. +## Requirements +**As of v1.0.0**, this tool targets **.NET 10.0** and requires the **.NET 10.0 SDK or later**. Versions prior to v1.0.0 targeted .NET 7.0. + +### Compatibility +Since v1.0.0, generated model files use `System.Text.Json` instead of `Newtonsoft.Json`. Because generated code references `Contentstack.Core.Models` and `Contentstack.Utils.Interfaces` / `ContentstackHelper` directly, your consuming project must reference System.Text.Json-based SDK versions for the generated code to compile: + +| Package | Minimum Version | +|---|---| +| `contentstack.csharp` (Delivery SDK) | `3.0.0` | +| `contentstack.management.csharp` (Management SDK) | `1.0.0` | +| `contentstack.utils` | `2.0.0` | + +Generating models into a project still referencing pre-3.0.0 / pre-1.0.0 / pre-2.0.0 (Newtonsoft-based) SDK versions will produce code that fails to compile. + ## Installation To install Contenstack model generator run following command: ``` @@ -27,11 +41,11 @@ The Contentstack Model Generator supports two authentication methods: | `-A` | `--authtoken` | The Authtoken for the Content Management API (required for traditional auth) | | `-b` | `--branch` | The branch header in the API request to fetch or manage modules located within specific branches. | | `-e` | `--endpoint` | The Contentstack Host for the Content Management API | -| `-n` | `--namespace` | The namespace the classes should be created in | +| `-n` | `--namespace` | The namespace the classes should be created in (Default Value: ContentstackModels) | | `-N` | `--is-nullable` | The features that protect against throwing a System.NullReferenceException can be disruptive when turned on. | | `-f` | `--force` | Automatically overwrite files that already exist | -| `-m` | `--modular-block-prefix` | The Modular block Class Prefix. | -| `-g` | `--group-prefix` | The Group Class Prefix. | +| `-m` | `--modular-block-prefix` | The Modular block Class Prefix. (Default Value: MB) | +| `-g` | `--group-prefix` | The Group Class Prefix. (Default Value: Group) | | `-p` | `--path` | Path to the file or directory to create files in. | ### OAuth 2.0 Options @@ -106,40 +120,72 @@ Here's what you'll see when running an OAuth command: $ contentstack.model.generator --oauth -a --client-id myclient123 --redirect-uri http://localhost:8184 -Contentstack Model Generator v0.5.0 -===================================== - OAuth Authentication Required ============================= Please open the following URL in your browser to authorize the application: -https://app.contentstack.com/#!/apps/6400aa06db64de001a31c8a9/authorize?response_type=code&client_id=myclient123&redirect_uri=http%3A%2F%2Flocalhost%3A8184&code_challenge=... +https://app.contentstack.com/#!/apps/6400aa06db64de001a31c8a9/authorize?response_type=code&client_id=myclient123&redirect_uri=http%3A%2F%2Flocalhost%3A8184&code_challenge=...&code_challenge_method=S256 After authorization, you will be redirected to a local URL. Please copy the 'code' parameter from the redirect URL and paste it here: -Authorization code: [User pastes the code here] +Authorization code: [paste the code here] Exchanging authorization code for access token... OAuth authentication successful! -Access token expires at: 2024-01-15 14:30:00 UTC +Access token expires at: 2026-01-15 14:30:00 UTC +Fetching stack details for the provided API key. +No path specified. Generating files in the current working directory: /Users/you/project. +Fetching content types from My Contentstack Stack stack. +Found 5 content types. +Total content types fetched: 5. +Fetching global fields from stack: My Contentstack Stack. +Found 2 global fields. +Total global fields fetched: 2. +Generating files from content types. +Files created successfully. +Opening output directory: /Users/you/project. + +Logging out from OAuth... +OAuth logout successful! +``` -Fetching stack information... -Stack: My Contentstack Stack -API Key: api_key +## Generated Code -Fetching content types... -Found 5 content types: -Generating files from content type +**As of v1.0.0**, generated model files use **System.Text.Json** instead of Newtonsoft.Json: -Files successfully created! -Opening /Models +- Properties are decorated with `[JsonPropertyName("...")]` instead of `[JsonProperty(propertyName: "...")]`. +- Generated files reference `System.Text.Json`, `System.Text.Json.Nodes`, and `System.Text.Json.Serialization` instead of `Newtonsoft.Json`. +- Generated converters (for embedded objects and modular blocks) inherit `System.Text.Json.Serialization.JsonConverter` and implement `Read`/`Write` instead of Newtonsoft's `JsonConverter` with `ReadJson`/`WriteJson`. -Logging out from OAuth... -OAuth logout successful! +A generated model file looks like this: + +```csharp +using System; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Contentstack.Core.Models; +using Contentstack.Utils.Interfaces; + +namespace ContentstackModels.Models +{ + public partial class BlogPost : IEmbeddedObject + { + public const string ContentType = "blog_post"; + [JsonPropertyName("uid")] + public string Uid { get; set; } + [JsonPropertyName("_content_type_uid")] + public string ContentTypeUid { get; set; } + [JsonPropertyName("title")] + public string Title { get; set; } + } +} ``` +**Breaking change:** if you regenerate models for an existing project using v1.0.0 or later, the attributes and converter base classes in the output will change from the Newtonsoft-based shape to the System.Text.Json-based shape shown above. Update your consuming code and Contentstack .NET SDK versions accordingly before regenerating. + ### MIT License Copyright (c) 2012-2026 Contentstack diff --git a/Scripts/generate_test_report.py b/Scripts/generate_test_report.py new file mode 100644 index 0000000..819cfd7 --- /dev/null +++ b/Scripts/generate_test_report.py @@ -0,0 +1,614 @@ +#!/usr/bin/env python3 +""" +HTML Test Report Generator for contentstack-model-generator. + +Converts a .trx test-results file (and an optional Cobertura coverage.xml) +into a single self-contained HTML report: pass/fail summary, per-class test +breakdown with failure details, and a code coverage section. + +Usage: + python3 Scripts/generate_test_report.py [path-to-coverage.cobertura.xml] + +Or run tests first to generate both inputs: + dotnet test contentstack.model.generator.tests/contentstack.model.generator.tests.csproj \\ + --logger "trx;LogFileName=test-results.trx" \\ + --collect:"XPlat Code Coverage" \\ + --results-directory contentstack.model.generator.tests/TestResults + +Uses only the Python standard library (xml.etree.ElementTree) - no extra +dependencies to install. The .trx/coverage files parsed here are always +locally generated by `dotnet test`, not untrusted input. +""" + +import glob +import os +import sys +import xml.etree.ElementTree as ET +from datetime import datetime + +TRX_NS = {'': 'http://microsoft.com/schemas/VisualStudio/TeamTest/2010'} + + +def escape_html(text): + if text is None: + return "" + text = str(text) + return (text + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') + .replace("'", ''')) + + +class TestReportGenerator: + def __init__(self, trx_file, coverage_file=None): + self.trx_file = trx_file + self.coverage_file = coverage_file + self.results = { + 'total': 0, + 'passed': 0, + 'failed': 0, + 'skipped': 0, + 'tests': [], + } + self.coverage = None + + def parse_trx(self): + tree = ET.parse(self.trx_file) + root = tree.getroot() + + result_summary = root.find('.//ResultSummary', TRX_NS) + counters = result_summary.find('Counters', TRX_NS) if result_summary is not None else None + if counters is not None: + self.results['total'] = int(counters.get('total', 0)) + self.results['passed'] = int(counters.get('passed', 0)) + self.results['failed'] = int(counters.get('failed', 0)) + self.results['skipped'] = int(counters.get('notExecuted', 0)) + + unit_tests_by_id = {el.get('id'): el for el in root.findall('.//UnitTest', TRX_NS)} + + for test_result in root.findall('.//UnitTestResult', TRX_NS): + test_name = test_result.get('testName', 'Unknown') + outcome = test_result.get('outcome', 'Unknown') + duration = test_result.get('duration', '0') + + try: + hours, minutes, seconds = duration.split(':') + total_seconds = int(hours) * 3600 + int(minutes) * 60 + float(seconds) + duration_str = f"{total_seconds:.3f}s" + except (ValueError, AttributeError): + duration_str = duration + + error_message = None + error_stacktrace = None + output_elem = test_result.find('Output', TRX_NS) + if output_elem is not None: + error_info = output_elem.find('ErrorInfo', TRX_NS) + if error_info is not None: + message_elem = error_info.find('Message', TRX_NS) + stacktrace_elem = error_info.find('StackTrace', TRX_NS) + if message_elem is not None: + error_message = message_elem.text + if stacktrace_elem is not None: + error_stacktrace = stacktrace_elem.text + + test_def = unit_tests_by_id.get(test_result.get('testId', '')) + category = 'General' + if test_def is not None: + test_method = test_def.find('.//TestMethod', TRX_NS) + if test_method is not None: + class_name = test_method.get('className', '') + if class_name: + category = class_name.split('.')[-1] + + self.results['tests'].append({ + 'name': test_name, + 'outcome': outcome, + 'duration': duration_str, + 'category': category, + 'error_message': error_message, + 'error_stacktrace': error_stacktrace, + }) + + return True + + def parse_coverage(self): + if not self.coverage_file or not os.path.exists(self.coverage_file): + return + + tree = ET.parse(self.coverage_file) + root = tree.getroot() + + # ModelGenerator.cs (and any other file) is split across multiple + # entries by coverlet (the class itself plus one per + # compiler-generated async state machine) - aggregate by filename + # so each source file shows as a single row. + files = {} + for cls in root.iter('class'): + filename = cls.get('filename', cls.get('name', 'unknown')) + lines_el = cls.find('lines') + if lines_el is None: + continue + entry = files.setdefault(filename, {'valid': 0, 'covered': 0}) + for line in lines_el.findall('line'): + entry['valid'] += 1 + if int(line.get('hits', 0)) > 0: + entry['covered'] += 1 + + classes = [] + total_valid = 0 + total_covered = 0 + for filename, stats in files.items(): + valid = stats['valid'] + covered = stats['covered'] + total_valid += valid + total_covered += covered + classes.append({ + 'filename': filename, + 'lines_valid': valid, + 'lines_covered': covered, + 'line_rate': (covered / valid * 100) if valid else 0.0, + }) + + classes.sort(key=lambda c: c['line_rate']) + + self.coverage = { + 'line_rate': float(root.get('line-rate', 0)) * 100, + 'branch_rate': float(root.get('branch-rate', 0)) * 100, + 'lines_valid': total_valid, + 'lines_covered': total_covered, + 'classes': classes, + } + + def generate_coverage_section_html(self): + if not self.coverage: + return "" + + cov = self.coverage + rows_html = "" + for c in cov['classes']: + rate = c['line_rate'] + bar_class = 'good' if rate >= 80 else 'warn' if rate >= 50 else 'bad' + rows_html += f""" + + {escape_html(c['filename'])} + +
+
+
+ + {rate:.1f}% + {c['lines_covered']} / {c['lines_valid']} + +""" + + return f""" +
+

Code Coverage

+
+
+
{cov['line_rate']:.1f}%
+
Line Coverage
+
+
+
{cov['branch_rate']:.1f}%
+
Branch Coverage
+
+
+
{cov['lines_covered']}
+
Lines Covered
+
+
+
{cov['lines_valid']}
+
Total Lines
+
+
+ + + + + + + + + + +{rows_html} + +
FileLine Coverage%Lines
+
+""" + + def generate_html(self, output_file): + pass_rate = (self.results['passed'] / self.results['total'] * 100) if self.results['total'] > 0 else 0 + + tests_by_category = {} + for test in self.results['tests']: + tests_by_category.setdefault(test['category'], []).append(test) + sorted_categories = sorted(tests_by_category.keys()) + + html = f""" + + + + + contentstack-model-generator - Test Report + + + +
+
+

contentstack-model-generator Test Report

+

{datetime.now().strftime('%B %d, %Y at %I:%M %p')}

+
+ +
+
+
{self.results['total']}
+
Total Tests
+
+
+
{self.results['passed']}
+
Passed
+
+
+
{self.results['failed']}
+
Failed
+
+
+
{self.results['skipped']}
+
Skipped
+
+
+ +
+

Pass Rate

+
+
{pass_rate:.1f}%
+
+
+{self.generate_coverage_section_html()} +
+

Test Results by Class

+""" + + for category in sorted_categories: + tests = tests_by_category[category] + passed = sum(1 for t in tests if t['outcome'] == 'Passed') + failed = sum(1 for t in tests if t['outcome'] == 'Failed') + skipped = sum(1 for t in tests if t['outcome'] == 'NotExecuted') + + html += f""" +
+
+
+ + {escape_html(category)} +
+
+ {passed} passed · + {failed} failed · + {skipped} skipped · + {len(tests)} total +
+
+
+ + + + + + + + + +""" + + for test_idx, test in enumerate(tests): + status_class = ('status-passed' if test['outcome'] == 'Passed' + else 'status-failed' if test['outcome'] == 'Failed' + else 'status-skipped') + test_id = f"test-{category}-{test_idx}" + + html += f""" + + + + + +""" + + html += """ + +
Test NameStatusDuration
+
{escape_html(test['name'])}
+""" + if test['outcome'] == 'Failed' and (test['error_message'] or test['error_stacktrace']): + html += f""" +
+
Error:
{escape_html(test['error_message'])}
+""" + if test['error_stacktrace']: + html += f""" +
+ Stack Trace +
{escape_html(test['error_stacktrace'])}
+
+""" + html += """ +
+""" + + html += f""" +
{test['outcome']}{test['duration']}
+
+
+""" + + html += f""" +
+ + +
+ + + + +""" + + with open(output_file, 'w', encoding='utf-8') as f: + f.write(html) + + print(f"Test report generated: {output_file}") + return output_file + + +def find_first(pattern): + matches = glob.glob(pattern, recursive=True) + return matches[0] if matches else None + + +def main(): + print("=" * 80) + print("contentstack-model-generator Test Report Generator") + print("=" * 80) + + trx_file = sys.argv[1] if len(sys.argv) > 1 else find_first('contentstack.model.generator.tests/TestResults/**/*.trx') + coverage_file = sys.argv[2] if len(sys.argv) > 2 else find_first('contentstack.model.generator.tests/TestResults/**/coverage.cobertura.xml') + + if not trx_file or not os.path.exists(trx_file): + print("\nError: No .trx file found!") + print(__doc__) + sys.exit(1) + + print(f"\nTRX file: {trx_file}") + print(f"Coverage file: {coverage_file or '(none found - report will omit coverage section)'}") + + generator = TestReportGenerator(trx_file, coverage_file) + + print("\nParsing test results...") + generator.parse_trx() + print(f"Found {generator.results['total']} tests " + f"({generator.results['passed']} passed, {generator.results['failed']} failed, " + f"{generator.results['skipped']} skipped)") + + if coverage_file: + print("\nParsing coverage data...") + generator.parse_coverage() + if generator.coverage: + print(f"Line coverage: {generator.coverage['line_rate']:.1f}% " + f"({generator.coverage['lines_covered']}/{generator.coverage['lines_valid']} lines)") + + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + output_file = generator.generate_html(f'test-report_{timestamp}.html') + + print("\n" + "=" * 80) + print(f"Done. Open: file://{os.path.abspath(output_file)}") + print("=" * 80) + + +if __name__ == "__main__": + main() diff --git a/contentstack.model.generator.tests/ConfigTests.cs b/contentstack.model.generator.tests/ConfigTests.cs new file mode 100644 index 0000000..18ad7cd --- /dev/null +++ b/contentstack.model.generator.tests/ConfigTests.cs @@ -0,0 +1,103 @@ +using contentstack.CMA; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class ConfigTests + { + [Fact] + public void Port_DefaultValue_Is443() + { + var config = new Config(); + + Assert.Equal("443", config.Port); + } + + [Fact] + public void Protocol_DefaultValue_IsHttps() + { + var config = new Config(); + + Assert.Equal("https", config.Protocol); + } + + [Fact] + public void Host_DefaultValue_IsHostURL() + { + var config = new Config(); + + Assert.Equal("cdn.contentstack.io", config.Host); + } + + [Fact] + public void Version_DefaultValue_IsV3() + { + var config = new Config(); + + Assert.Equal("v3", config.Version); + } + + [Fact] + public void Port_Setter_OverridesDefaultValue() + { + var config = new Config { Port = "8080" }; + + Assert.Equal("8080", config.Port); + } + + [Fact] + public void Host_Setter_OverridesDefaultValue() + { + var config = new Config { Host = "api.contentstack.io" }; + + Assert.Equal("api.contentstack.io", config.Host); + } + + [Fact] + public void Version_Setter_OverridesDefaultValue() + { + var config = new Config { Version = "v4" }; + + Assert.Equal("v4", config.Version); + } + + [Fact] + public void BaseUrl_WithDefaults_CombinesProtocolHostAndVersion() + { + var config = new Config(); + + Assert.Equal("https://cdn.contentstack.io/v3", config.BaseUrl); + } + + [Fact] + public void BaseUrl_WithOverriddenHostAndVersion_ReflectsOverrides() + { + var config = new Config { Host = "api.contentstack.io", Version = "v4" }; + + Assert.Equal("https://api.contentstack.io/v4", config.BaseUrl); + } + + [Fact] + public void BaseUrl_TrimsSlashesFromComponents() + { + var config = new Config { Host = "/api.contentstack.io/", Version = "/v3/" }; + + Assert.Equal("https://api.contentstack.io/v3", config.BaseUrl); + } + + [Fact] + public void ApiKey_AppUid_AuthToken_CanBeSetAndRetrieved() + { + var config = new Config + { + ApiKey = "test_api_key", + AppUid = "test_app_uid", + AuthToken = "test_auth_token" + }; + + Assert.Equal("test_api_key", config.ApiKey); + Assert.Equal("test_app_uid", config.AppUid); + Assert.Equal("test_auth_token", config.AuthToken); + } + } +} diff --git a/contentstack.model.generator.tests/ContentstackClientTests.cs b/contentstack.model.generator.tests/ContentstackClientTests.cs index 677e080..2ce54c6 100644 --- a/contentstack.model.generator.tests/ContentstackClientTests.cs +++ b/contentstack.model.generator.tests/ContentstackClientTests.cs @@ -59,20 +59,20 @@ public void Constructor_ShouldHandleNullOptions() } [Fact] - public void Constructor_ShouldSetSerializerSettings() + public void Constructor_ShouldSetSerializerOptions() { - + var options = new ContentstackOptions { ApiKey = "test_api_key", Host = "api.contentstack.io" }; - + var client = new ContentstackClient(options); - - Assert.NotNull(client.SerializerSettings); + + Assert.NotNull(client.SerializerOptions); } [Theory] diff --git a/contentstack.model.generator.tests/ContentstackConstantsTests.cs b/contentstack.model.generator.tests/ContentstackConstantsTests.cs new file mode 100644 index 0000000..599ac2f --- /dev/null +++ b/contentstack.model.generator.tests/ContentstackConstantsTests.cs @@ -0,0 +1,90 @@ +using contentstack.CMA; +using Xunit; + +namespace contentstack.model.generator.tests +{ + /// + /// ContentstackConstants has a private constructor - the only way to obtain an + /// instance (even from this InternalsVisibleTo test assembly) is via the internal + /// static Instance property. + /// + public class ContentstackConstantsTests + { + [Fact] + public void Instance_ReturnsNonNullInstance() + { + var instance = ContentstackConstants.Instance; + + Assert.NotNull(instance); + } + + [Fact] + public void Instance_EachAccess_ReturnsNewInstance() + { + var first = ContentstackConstants.Instance; + var second = ContentstackConstants.Instance; + + Assert.NotSame(first, second); + } + + [Fact] + public void ContentTypeUid_CanBeSetAndRetrieved() + { + var instance = ContentstackConstants.Instance; + + instance.ContentTypeUid = "blog_post"; + + Assert.Equal("blog_post", instance.ContentTypeUid); + } + + [Fact] + public void EntryUid_CanBeSetAndRetrieved() + { + var instance = ContentstackConstants.Instance; + + instance.EntryUid = "entry_123"; + + Assert.Equal("entry_123", instance.EntryUid); + } + + [Fact] + public void Content_Types_DefaultValue_ReturnsContentTypes() + { + var instance = ContentstackConstants.Instance; + + Assert.Equal("content_types", instance.Content_Types); + } + + [Fact] + public void Content_Types_Setter_OverridesValue() + { + var instance = ContentstackConstants.Instance; + + instance.Content_Types = "custom_content_types"; + + Assert.Equal("custom_content_types", instance.Content_Types); + } + + [Fact] + public void Entries_DefaultValue_ReturnsContentTypesDueToSharedBackingField() + { + // Content_Types and Entries read/write the SAME private backing field + // (initialized to "content_types"), so Entries' "?? \"entries\"" fallback + // is unreachable on a fresh instance - this pins current behavior. + var instance = ContentstackConstants.Instance; + + Assert.Equal("content_types", instance.Entries); + } + + [Fact] + public void Entries_Setter_AlsoChangesContentTypesValue() + { + var instance = ContentstackConstants.Instance; + + instance.Entries = "entries"; + + Assert.Equal("entries", instance.Entries); + Assert.Equal("entries", instance.Content_Types); + } + } +} diff --git a/contentstack.model.generator.tests/ContentstackExceptionTests.cs b/contentstack.model.generator.tests/ContentstackExceptionTests.cs new file mode 100644 index 0000000..e43b4a3 --- /dev/null +++ b/contentstack.model.generator.tests/ContentstackExceptionTests.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Net; +using contentstack.CMA; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class ContentstackExceptionTests + { + [Fact] + public void DefaultConstructor_CreatesInstanceWithEmptyErrorMessage() + { + var exception = new ContentstackException(); + + Assert.Equal(string.Empty, exception.ErrorMessage); + } + + [Fact] + public void Constructor_WithMessage_SetsBaseMessageAndErrorMessage() + { + var exception = new ContentstackException("something failed"); + + Assert.Equal("something failed", exception.Message); + Assert.Equal("something failed", exception.ErrorMessage); + } + + [Fact] + public void Constructor_WithInnerException_SetsErrorMessageFromInnerExceptionMessage() + { + var inner = new InvalidOperationException("inner failure"); + + var exception = new ContentstackException(inner); + + Assert.Equal("inner failure", exception.ErrorMessage); + Assert.Equal("inner failure", exception.Message); + } + + [Fact] + public void Constructor_WithMessageAndInnerException_SetsBaseMessageButNotShadowedMessageProperty() + { + // ContentstackException declares `public new string Message`, which shadows + // Exception.Message. This constructor forwards to base(message, innerException) + // (so the base Exception's Message is set) but - unlike the other constructors - + // never sets ErrorMessage, so the shadowing `Message` property itself stays null. + var inner = new InvalidOperationException("root cause"); + + var exception = new ContentstackException("wrapped failure", inner); + + Assert.Null(exception.Message); + Assert.Equal("wrapped failure", ((Exception)exception).Message); + Assert.Same(inner, exception.InnerException); + } + + [Fact] + public void ErrorMessage_Setter_AlsoUpdatesMessageProperty() + { + var exception = new ContentstackException(); + + exception.ErrorMessage = "updated message"; + + Assert.Equal("updated message", exception.Message); + } + + [Fact] + public void StatusCode_CanBeSetAndRetrieved() + { + var exception = new ContentstackException { StatusCode = HttpStatusCode.NotFound }; + + Assert.Equal(HttpStatusCode.NotFound, exception.StatusCode); + } + + [Fact] + public void ErrorCode_CanBeSetAndRetrieved() + { + var exception = new ContentstackException { ErrorCode = 141 }; + + Assert.Equal(141, exception.ErrorCode); + } + + [Fact] + public void Errors_CanBeSetAndRetrieved() + { + var errors = new Dictionary { { "title", "is required" } }; + + var exception = new ContentstackException { Errors = errors }; + + Assert.Same(errors, exception.Errors); + } + } +} diff --git a/contentstack.model.generator.tests/ContentstackHttpRequestTests.cs b/contentstack.model.generator.tests/ContentstackHttpRequestTests.cs new file mode 100644 index 0000000..83b7196 --- /dev/null +++ b/contentstack.model.generator.tests/ContentstackHttpRequestTests.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using contentstack.CMA.Http; +using Xunit; + +namespace contentstack.model.generator.tests +{ + /// + /// Tests for contentstack.CMA.Http.ContentstackHttpRequest - part of an HTTP + /// abstraction layer (CMA/Http/*) that is not currently wired into + /// ContentstackClient. Its constructor takes an HttpClient directly (unlike + /// CMA/HTTPRequestHandler.cs, which hardcodes `new HttpClient()`), so it can be + /// fully tested by backing the HttpClient with a fake HttpMessageHandler - no + /// real network call, no mocking framework needed. + /// + public class ContentstackHttpRequestTests + { + private class FakeHttpMessageHandler : HttpMessageHandler + { + private readonly Func _responder; + + public HttpRequestMessage LastRequest { get; private set; } + + public FakeHttpMessageHandler(Func responder) + { + _responder = responder; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequest = request; + return Task.FromResult(_responder(request)); + } + } + + private static ContentstackHttpRequest CreateRequest( + out FakeHttpMessageHandler handler, + HttpStatusCode statusCode = HttpStatusCode.OK, + string body = "{\"ok\":true}") + { + handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(statusCode) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }); + var httpClient = new HttpClient(handler); + return new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()) + { + RequestUri = new Uri("http://localhost/test") + }; + } + + [Fact] + public void Constructor_ExposesUnderlyingHttpClient() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()); + + Assert.Same(httpClient, request.HttpClient); + } + + [Fact] + public void Method_Getter_ReflectsUnderlyingRequestMessage() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()) + { + Method = HttpMethod.Post + }; + + Assert.Equal(HttpMethod.Post, request.Method); + Assert.Equal(HttpMethod.Post, request.Request.Method); + } + + [Fact] + public async Task GetResponseAsync_SuccessStatusCode_ReturnsContentstackResponse() + { + var request = CreateRequest(out _, HttpStatusCode.OK, "{\"name\":\"test\"}"); + + var response = await request.GetResponseAsync(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("{\"name\":\"test\"}", response.OpenResponse()); + } + + [Fact] + public async Task GetResponseAsync_SendsConfiguredRequestUri() + { + var request = CreateRequest(out var handler, HttpStatusCode.OK, "{}"); + + await request.GetResponseAsync(); + + Assert.Equal(new Uri("http://localhost/test"), handler.LastRequest.RequestUri); + } + + [Fact] + public async Task GetResponseAsync_AmbiguousRangeStatusCode_ReturnsResponseWithoutThrowing() + { + var request = CreateRequest(out _, HttpStatusCode.MultipleChoices, "{}"); + + var response = await request.GetResponseAsync(); + + Assert.Equal(HttpStatusCode.MultipleChoices, response.StatusCode); + } + + [Fact] + public async Task GetResponseAsync_ErrorStatusCode_ThrowsHttpRequestException() + { + var request = CreateRequest(out _, HttpStatusCode.NotFound, "{}"); + + await Assert.ThrowsAsync(() => request.GetResponseAsync()); + } + + [Fact] + public void GetResponse_SuccessStatusCode_ReturnsContentstackResponse() + { + var request = CreateRequest(out _, HttpStatusCode.OK, "{}"); + + var response = request.GetResponse(); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public void GetResponse_ErrorStatusCode_UnwrapsAggregateExceptionAndThrowsHttpRequestException() + { + var request = CreateRequest(out _, HttpStatusCode.NotFound, "{}"); + + Assert.Throws(() => request.GetResponse()); + } + + [Fact] + public void SetRequestHeaders_AddsHeadersToRequest() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()); + + request.SetRequestHeaders(new Dictionary { { "api_key", "test_key" } }); + + Assert.True(request.Request.Headers.TryGetValues("api_key", out var values)); + Assert.Contains("test_key", values); + } + + [Fact] + public void WriteToRequestBody_SetsRequestContent() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()); + var content = new StringContent("{}"); + + request.WriteToRequestBody(content); + + Assert.Same(content, request.GetRequestContent()); + } + + [Fact] + public void WriteToRequestBody_WithContentHeaders_SetsContentTypeHeader() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()); + var content = new StringContent("{}"); + + request.WriteToRequestBody(content, new Dictionary { { "Content-Type", "application/json" } }); + + Assert.Equal("application/json", request.Request.Content.Headers.ContentType.MediaType); + } + + [Fact] + public void Dispose_IsIdempotent() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()); + + request.Dispose(); + var exception = Record.Exception(() => request.Dispose()); + + Assert.Null(exception); + } + + [Fact] + public void SetRequestHeaders_AfterDispose_ThrowsObjectDisposedException() + { + var httpClient = new HttpClient(new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK))); + var request = new ContentstackHttpRequest(httpClient, new JsonSerializerOptions()); + request.Dispose(); + + Assert.Throws(() => request.SetRequestHeaders(new Dictionary())); + } + } +} diff --git a/contentstack.model.generator.tests/ContentstackHttpResponseTests.cs b/contentstack.model.generator.tests/ContentstackHttpResponseTests.cs new file mode 100644 index 0000000..91eeae1 --- /dev/null +++ b/contentstack.model.generator.tests/ContentstackHttpResponseTests.cs @@ -0,0 +1,167 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; +using contentstack.CMA.Http; +using Xunit; + +namespace contentstack.model.generator.tests +{ + /// + /// Tests for contentstack.CMA.Http.ContentstackResponse - part of an HTTP + /// abstraction layer (CMA/Http/*) that is not currently wired into + /// ContentstackClient (which still uses CMA/HTTPRequestHandler.cs). Its + /// constructor takes a plain HttpResponseMessage, so it's fully testable in + /// isolation without any real network call. + /// + public class ContentstackHttpResponseTests + { + private class SamplePayload + { + public string Name { get; set; } + } + + private static HttpResponseMessage CreateResponse( + HttpStatusCode statusCode = HttpStatusCode.OK, + string body = "{\"name\":\"test\"}", + string mediaType = "application/json") + { + var response = new HttpResponseMessage(statusCode) + { + Content = new StringContent(body, Encoding.UTF8, mediaType) + }; + response.Headers.Add("X-Custom-Header", "custom-value"); + return response; + } + + [Fact] + public void Constructor_SetsStatusCodeAndSuccessFlag() + { + var response = new ContentstackResponse(CreateResponse(HttpStatusCode.OK), new JsonSerializerOptions()); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(response.IsSuccessStatusCode); + } + + [Fact] + public void Constructor_WithErrorStatusCode_SetsSuccessFlagFalse() + { + var response = new ContentstackResponse(CreateResponse(HttpStatusCode.NotFound), new JsonSerializerOptions()); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.False(response.IsSuccessStatusCode); + } + + [Fact] + public void Constructor_SetsContentTypeAndContentLength() + { + var response = new ContentstackResponse(CreateResponse(body: "{\"a\":1}"), new JsonSerializerOptions()); + + Assert.Equal("application/json", response.ContentType); + Assert.Equal(Encoding.UTF8.GetByteCount("{\"a\":1}"), response.ContentLength); + } + + [Fact] + public void ResponseBody_ReturnsUnderlyingHttpResponseMessage() + { + var httpResponse = CreateResponse(); + + var response = new ContentstackResponse(httpResponse, new JsonSerializerOptions()); + + Assert.Same(httpResponse, response.ResponseBody); + } + + [Fact] + public void GetHeaderNames_IncludesResponseAndContentHeaders() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + + var headerNames = response.GetHeaderNames(); + + Assert.Contains("X-Custom-Header", headerNames); + Assert.Contains("Content-Type", headerNames); + } + + [Fact] + public void GetHeaderValue_ReturnsMatchingHeaderValue() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + + Assert.Equal("custom-value", response.GetHeaderValue("X-Custom-Header")); + } + + [Fact] + public void GetHeaderValue_MissingHeader_ReturnsEmptyString() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + + Assert.Equal(string.Empty, response.GetHeaderValue("Does-Not-Exist")); + } + + [Fact] + public void IsHeaderPresent_ForExistingHeader_ReturnsTrue() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + + Assert.True(response.IsHeaderPresent("X-Custom-Header")); + } + + [Fact] + public void IsHeaderPresent_ForMissingHeader_ReturnsFalse() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + + Assert.False(response.IsHeaderPresent("Does-Not-Exist")); + } + + [Fact] + public void OpenResponse_ReturnsRawBody() + { + var response = new ContentstackResponse(CreateResponse(body: "{\"name\":\"test\"}"), new JsonSerializerOptions()); + + Assert.Equal("{\"name\":\"test\"}", response.OpenResponse()); + } + + [Fact] + public void OpenJsonObjectResponse_ParsesBodyIntoJsonObject() + { + var response = new ContentstackResponse(CreateResponse(body: "{\"name\":\"test\"}"), new JsonSerializerOptions()); + + var jObject = response.OpenJsonObjectResponse(); + + Assert.Equal("test", jObject["name"].GetValue()); + } + + [Fact] + public void OpenTResponse_DeserializesBodyIntoRequestedType() + { + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var response = new ContentstackResponse(CreateResponse(body: "{\"name\":\"test\"}"), options); + + var result = response.OpenTResponse(); + + Assert.Equal("test", result.Name); + } + + [Fact] + public void Dispose_IsIdempotent() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + + response.Dispose(); + var exception = Record.Exception(() => response.Dispose()); + + Assert.Null(exception); + } + + [Fact] + public void OpenResponse_AfterDispose_ThrowsObjectDisposedException() + { + var response = new ContentstackResponse(CreateResponse(), new JsonSerializerOptions()); + response.Dispose(); + + Assert.Throws(() => response.OpenResponse()); + } + } +} diff --git a/contentstack.model.generator.tests/ContentstackResponseModelTests.cs b/contentstack.model.generator.tests/ContentstackResponseModelTests.cs new file mode 100644 index 0000000..b60e08d --- /dev/null +++ b/contentstack.model.generator.tests/ContentstackResponseModelTests.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; +using Contentstack.Model.Generator.Model; +using contentstack.model.generator.Model; +using Xunit; + +namespace contentstack.model.generator.tests +{ + /// + /// Tests for Contentstack.Model.Generator.Model.ContentstackResponse - the DTO + /// ContentstackClient.GetContentTypes()/GetGlobalFields() deserialize into. Tested + /// directly against the type rather than through ContentstackClient, since the + /// latter requires a real network call. + /// + public class ContentstackResponseModelTests + { + [Fact] + public void DefaultConstructor_CreatesInstance() + { + var response = new ContentstackResponse(); + + Assert.NotNull(response); + } + + [Fact] + public void ListContentTypes_CanBeSetAndRetrieved() + { + var list = new List { new Contenttype { Uid = "blog" } }; + + var response = new ContentstackResponse { listContentTypes = list }; + + Assert.Same(list, response.listContentTypes); + } + + [Fact] + public void Count_CanBeSetAndRetrieved() + { + var response = new ContentstackResponse { Count = 42 }; + + Assert.Equal(42, response.Count); + } + } +} diff --git a/contentstack.model.generator.tests/MessagesTests.cs b/contentstack.model.generator.tests/MessagesTests.cs new file mode 100644 index 0000000..c0a4f79 --- /dev/null +++ b/contentstack.model.generator.tests/MessagesTests.cs @@ -0,0 +1,146 @@ +using contentstack.model.generator; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class MessagesTests + { + [Fact] + public void NoPathSpecified_FormatsPath() + { + Assert.Equal( + "No path specified. Generating files in the current working directory: /tmp.", + Messages.NoPathSpecified("/tmp")); + } + + [Fact] + public void OutputPathSpecified_FormatsPath() + { + Assert.Equal( + "Output path specified. Generating files at: /tmp.", + Messages.OutputPathSpecified("/tmp")); + } + + [Fact] + public void OutputPathNotFound_FormatsPath() + { + Assert.Equal("Output path not found. Creating: /tmp.", Messages.OutputPathNotFound("/tmp")); + } + + [Fact] + public void OpeningOutputDirectory_FormatsDirectory() + { + Assert.Equal("Opening output directory: /tmp.", Messages.OpeningOutputDirectory("/tmp")); + } + + [Fact] + public void FetchingContentTypes_FormatsStackName() + { + Assert.Equal("Fetching content types from MyStack stack.", Messages.FetchingContentTypes("MyStack")); + } + + [Fact] + public void FoundContentTypes_FormatsCount() + { + Assert.Equal("Found 5 content types.", Messages.FoundContentTypes(5)); + } + + [Fact] + public void FetchedContentTypes_FormatsCount() + { + Assert.Equal("Fetched 5 content types.", Messages.FetchedContentTypes(5)); + } + + [Fact] + public void TotalContentTypesFetched_FormatsCount() + { + Assert.Equal("Total content types fetched: 5.", Messages.TotalContentTypesFetched(5)); + } + + [Fact] + public void FetchingGlobalFields_FormatsStackName() + { + Assert.Equal("Fetching global fields from stack: MyStack.", Messages.FetchingGlobalFields("MyStack")); + } + + [Fact] + public void FoundGlobalFields_FormatsCount() + { + Assert.Equal("Found 3 global fields.", Messages.FoundGlobalFields(3)); + } + + [Fact] + public void FetchedGlobalFields_FormatsCount() + { + Assert.Equal("Fetched 3 global fields.", Messages.FetchedGlobalFields(3)); + } + + [Fact] + public void TotalGlobalFieldsFetched_FormatsCount() + { + Assert.Equal("Total global fields fetched: 3.", Messages.TotalGlobalFieldsFetched(3)); + } + + [Fact] + public void SkippingFile_FormatsFileName() + { + Assert.Equal("Skipping Blog.cs file.", Messages.SkippingFile("Blog.cs")); + } + + [Fact] + public void AddingFile_FormatsFileNameAndDirectory() + { + Assert.Equal("Adding Blog.cs file to /tmp", Messages.AddingFile("Blog.cs", "/tmp")); + } + + [Fact] + public void FieldDataType_FormatsDataType() + { + Assert.Equal("Field data type: text.", Messages.FieldDataType("text")); + } + + [Fact] + public void ExtractingModularBlocksInContentType_FormatsContentTypeName() + { + Assert.Equal( + "Extracting modular blocks in Blog Content Type", + Messages.ExtractingModularBlocksInContentType("Blog")); + } + + [Fact] + public void ExtractingGroupsInContentType_FormatsContentTypeName() + { + Assert.Equal( + "Extracting groups in Blog Content Type", + Messages.ExtractingGroupsInContentType("Blog")); + } + + [Fact] + public void ExtractingModularBlocksInGroup_FormatsGroupName() + { + Assert.Equal("Extracting modular blocks in Meta group.", Messages.ExtractingModularBlocksInGroup("Meta")); + } + + [Fact] + public void ExtractingGroupsInGroup_FormatsGroupName() + { + Assert.Equal("Extracting groups in Meta group.", Messages.ExtractingGroupsInGroup("Meta")); + } + + [Fact] + public void ConstantMessages_HaveExpectedValues() + { + Assert.Equal("Unexpected error occurred. See logs for details.", Messages.UnexpectedError); + Assert.Equal("Operation failed. See error details in logs.", Messages.OperationFailed); + Assert.Equal( + "API communication error. Check your network or configuration and try again.", + Messages.ApiCommunicationError); + Assert.Equal( + "Authentication failed. Verify your API key and auth token and try again.", + Messages.AuthenticationFailed); + Assert.Equal("Fetching stack details for the provided API key.", Messages.FetchingStackDetails); + Assert.Equal("Generating files from content types.", Messages.GeneratingFiles); + Assert.Equal("Files created successfully.", Messages.FilesCreatedSuccessfully); + } + } +} diff --git a/contentstack.model.generator.tests/MetaDataModelTests.cs b/contentstack.model.generator.tests/MetaDataModelTests.cs new file mode 100644 index 0000000..91856f8 --- /dev/null +++ b/contentstack.model.generator.tests/MetaDataModelTests.cs @@ -0,0 +1,32 @@ +using contentstack.model.generator.Model; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class MetaDataModelTests + { + [Fact] + public void DefaultValue_CanBeSetAndRetrieved() + { + var metaData = new MetaData { DefaultValue = "fallback" }; + + Assert.Equal("fallback", metaData.DefaultValue); + } + + [Fact] + public void IsRichText_CanBeSetAndRetrieved() + { + var metaData = new MetaData { IsRichText = true }; + + Assert.True(metaData.IsRichText); + } + + [Fact] + public void IsExtension_CanBeSetAndRetrieved() + { + var metaData = new MetaData { IsExtension = true }; + + Assert.True(metaData.IsExtension); + } + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorAddParamsTests.cs b/contentstack.model.generator.tests/ModelGeneratorAddParamsTests.cs new file mode 100644 index 0000000..ca6c94f --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorAddParamsTests.cs @@ -0,0 +1,234 @@ +using System.Collections.Generic; +using System.Text; +using contentstack.model.generator.Model; +using Xunit; +using static contentstack.model.generator.tests.ModelGeneratorTestHelpers; + +namespace contentstack.model.generator.tests +{ + public class ModelGeneratorAddParamsTests + { + #region Attribute + type emission per field type + + [Fact] + public void AddParams_TextField_EmitsJsonPropertyNameAndStringProperty() + { + var generator = CreateGenerator(); + var schema = new List { TextField("title") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("[JsonPropertyName(\"title\")]", content); + Assert.Contains("public string Title { get; set; }", content); + } + + [Fact] + public void AddParams_NumberField_EmitsDoubleProperty() + { + var generator = CreateGenerator(); + var schema = new List { NumberField("views") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("[JsonPropertyName(\"views\")]", content); + Assert.Contains("public double Views { get; set; }", content); + } + + [Fact] + public void AddParams_BooleanField_EmitsBoolProperty() + { + var generator = CreateGenerator(); + var schema = new List { BooleanField("published") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public bool Published { get; set; }", content); + } + + [Fact] + public void AddParams_DateField_EmitsDateTimeProperty() + { + var generator = CreateGenerator(); + var schema = new List { DateField("published_on") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public DateTime PublishedOn { get; set; }", content); + } + + [Fact] + public void AddParams_FileField_EmitsAssetProperty() + { + var generator = CreateGenerator(); + var schema = new List { FileField("cover_image") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public Asset CoverImage { get; set; }", content); + } + + [Fact] + public void AddParams_LinkField_EmitsContentstackLinkProperty() + { + var generator = CreateGenerator(); + var schema = new List { LinkTypeField("cta") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public ContentstackLink Cta { get; set; }", content); + } + + #endregion + + #region Reference / group / blocks + + [Fact] + public void AddParams_ReferenceFieldSingle_EmitsResolvedClassNameProperty() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var schema = new List { ReferenceField("related", SingleRef("blog_post")) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public BlogPost Related { get; set; }", content); + } + + [Fact] + public void AddParams_ReferenceFieldMultipleContentTypes_EmitsObjectProperty() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var schema = new List { ReferenceField("related", SingleRef("blog_post"), refMultipleContentType: true) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public object Related { get; set; }", content); + } + + [Fact] + public void AddParams_GroupField_EmitsGroupPrefixedTypeProperty() + { + var generator = CreateGenerator(); + var schema = new List { GroupField("meta", "Meta", new List()) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public GroupBlogMeta Meta { get; set; }", content); + } + + [Fact] + public void AddParams_BlocksField_EmitsModularBlockPrefixedTypeProperty() + { + var generator = CreateGenerator(); + var schema = new List { BlocksField("sections", "Sections", new List()) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public MBBlogSections Sections { get; set; }", content); + } + + #endregion + + #region Markdown special case + + [Fact] + public void AddParams_MarkdownTextField_EmitsGetSetHtmlConversionAndBackingStoreField() + { + var generator = CreateGenerator(); + var schema = new List { TextField("body", isMarkdown: true) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public string Body {", content); + Assert.Contains("this.BodyStore = value;", content); + Assert.Contains("return this.BodyStore.ToHtml();", content); + Assert.Contains("private string BodyStore = \"\";", content); + } + + [Fact] + public void AddParams_MarkdownTextField_WithIsMultiple_UsesToListHtml() + { + var generator = CreateGenerator(); + var schema = new List { TextField("body", isMarkdown: true, isMultiple: true) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("return this.BodyStore.ToListHtml();", content); + } + + [Fact] + public void AddParams_MarkdownTextField_PropertyDeclaration_NeverHasNullableSuffix() + { + var generator = CreateGenerator(isNullable: true); + var schema = new List { TextField("body", isMarkdown: true) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public string Body {", content); + Assert.DoesNotContain("public string? Body {", content); + } + + #endregion + + #region Multiplicity + ordering + + [Fact] + public void AddParams_MultipleValueField_WrapsPropertyTypeInList() + { + var generator = CreateGenerator(); + var schema = new List { NumberField("scores", isMultiple: true) }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains("public List Scores { get; set; }", content); + } + + [Fact] + public void AddParams_MultipleFields_PreservesSchemaOrderInOutput() + { + var generator = CreateGenerator(); + var schema = new List { TextField("a"), NumberField("b"), BooleanField("c") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + var indexA = content.IndexOf("[JsonPropertyName(\"a\")]"); + var indexB = content.IndexOf("[JsonPropertyName(\"b\")]"); + var indexC = content.IndexOf("[JsonPropertyName(\"c\")]"); + + Assert.True(indexA >= 0 && indexB >= 0 && indexC >= 0); + Assert.True(indexA < indexB); + Assert.True(indexB < indexC); + } + + #endregion + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorCreateFileTests.cs b/contentstack.model.generator.tests/ModelGeneratorCreateFileTests.cs new file mode 100644 index 0000000..d31043b --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorCreateFileTests.cs @@ -0,0 +1,295 @@ +using System; +using System.Collections.Generic; +using System.IO; +using contentstack.model.generator.Model; +using Xunit; +using static contentstack.model.generator.tests.ModelGeneratorTestHelpers; + +namespace contentstack.model.generator.tests +{ + /// + /// CreateFile() end-to-end: class shape, RTE-reference extends-clause toggle, nested + /// blocks/group generation, using-directive emission, and the embedded-items + /// interpolation-bug regression. + /// + /// Not covered (intentionally): the "file already exists" overwrite-prompt branch in + /// shouldCreateFile() - with Force=false (the default) that calls Prompt.GetYesNo(), + /// which blocks on console input and would hang a headless test run. Every test here + /// uses a fresh unique temp directory so that branch is never reached. + /// + public class ModelGeneratorCreateFileTests : IDisposable + { + private readonly DirectoryInfo _tempDir; + + public ModelGeneratorCreateFileTests() + { + _tempDir = Directory.CreateTempSubdirectory("cmg_createfile_tests_"); + } + + private string ReadGenerated(string fileName) => + File.ReadAllText(Path.Combine(_tempDir.FullName, fileName)); + + #region Class shape + + [Fact] + public void CreateFile_SimpleContentType_WritesFileWithConstUidAndContentTypeUid() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog"); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.Contains("public const string ContentType = \"blog\";", content); + Assert.Contains("[JsonPropertyName(\"uid\")]", content); + Assert.Contains("public string Uid { get; set; }", content); + Assert.Contains("[JsonPropertyName(\"_content_type_uid\")]", content); + Assert.Contains("public string ContentTypeUid { get; set; }", content); + } + + [Fact] + public void CreateFile_SchemaWithoutRteReference_ExtendsIEmbeddedObjectOnly() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog", new List { TextField("title") }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.Contains("public partial class Blog : IEmbeddedObject", content); + Assert.DoesNotContain("IEntryEmbedable", content); + } + + [Fact] + public void CreateFile_SchemaWithRteReference_ExtendsIEntryEmbedableAndIEmbeddedObject() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog", new List { TextField("body", referenceTo: "some-rte-reference") }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.Contains("public partial class Blog : IEntryEmbedable, IEmbeddedObject", content); + } + + [Fact] + public void CreateFile_SchemaWithoutRteReference_OmitsEmbeddedItemsPropertyBlock() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog", new List { TextField("title") }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.DoesNotContain("embeddedItems", content); + } + + #endregion + + #region Nested modular blocks + + [Fact] + public void CreateFile_SchemaWithBlocksField_CreatesNestedModularBlockDirectoryAndFiles() + { + var generator = CreateGenerator(); + var blocks = new List { MakeContentType("heading", "Heading") }; + var contentType = MakeContentType("blog", "Blog", new List { BlocksField("sections", "Sections", blocks) }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var blocksDir = Path.Combine(_tempDir.FullName, "BlogBlocks"); + + Assert.True(Directory.Exists(blocksDir)); + Assert.True(File.Exists(Path.Combine(blocksDir, "MBBlogSectionsHeading.cs"))); + Assert.True(File.Exists(Path.Combine(blocksDir, "MBBlogSectionsEnum.cs"))); + Assert.True(File.Exists(Path.Combine(blocksDir, "MBBlogSections.cs"))); + Assert.True(File.Exists(Path.Combine(blocksDir, "MBBlogSectionsConverter.cs"))); + } + + [Fact] + public void CreateFile_SchemaWithBlocksField_AddsCorrectUsingDirectiveToTopLevelFile() + { + var generator = CreateGenerator(); + var blocks = new List { MakeContentType("heading", "Heading") }; + var contentType = MakeContentType("blog", "Blog", new List { BlocksField("sections", "Sections", blocks) }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.Contains($"using ContentstackModels.{_tempDir.Name}.BlogBlocks;", content); + } + + [Fact] + public void CreateFile_SchemaWithEmptyBlocksList_StillCreatesConverterFile() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog", new List { BlocksField("sections", "Sections", new List()) }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var blocksDir = Path.Combine(_tempDir.FullName, "BlogBlocks"); + var converterContent = File.ReadAllText(Path.Combine(blocksDir, "MBBlogSectionsConverter.cs")); + + Assert.Contains("return new MBBlogSections();", converterContent); + Assert.DoesNotContain("ContentstackHelper.FieldExists", converterContent); + Assert.False(File.Exists(Path.Combine(blocksDir, "MBBlogSectionsEnum.cs"))); + Assert.False(File.Exists(Path.Combine(blocksDir, "MBBlogSections.cs"))); + } + + [Fact] + public void CreateFile_SchemaWithBlocksFieldReferencingGlobalField_CreatesGlobalFieldModularFile() + { + var globalFieldSchema = new List + { + TextField("note"), + BlocksField("sub_blocks", "SubBlocks", new List()), + GroupField("sub_group", "SubGroup", new List()) + }; + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("global_heading", "Global Heading", globalFieldSchema) }); + var blocks = new List { MakeContentType("heading", "Heading", referenceTo: "global_heading") }; + var contentType = MakeContentType("blog", "Blog", new List { BlocksField("sections", "Sections", blocks) }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var blockFile = Path.Combine(_tempDir.FullName, "BlogBlocks", "MBBlogSectionsHeading.cs"); + + Assert.True(File.Exists(blockFile)); + var content = File.ReadAllText(blockFile); + Assert.Contains("[JsonPropertyName(\"note\")]", content); + Assert.Contains("public string Note { get; set; }", content); + Assert.Contains("using ContentstackModels.Models.GlobalHeadingBlocks;", content); + Assert.Contains("using ContentstackModels.Models.GlobalHeadingGroup;", content); + } + + [Fact] + public void CreateFile_NestedBlocksWithinBlock_AddsUsingDirectiveForNestedModularBlocks() + { + var nestedBlocks = new List { MakeContentType("nested_heading", "Nested Heading") }; + var innerSchema = new List { BlocksField("nested_sections", "NestedSections", nestedBlocks) }; + var blocks = new List { MakeContentType("heading", "Heading", innerSchema) }; + var contentType = MakeContentType("blog", "Blog", new List { BlocksField("sections", "Sections", blocks) }); + var generator = CreateGenerator(); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var blocksDir = Path.Combine(_tempDir.FullName, "BlogBlocks"); + var blockFile = Path.Combine(blocksDir, "MBBlogSectionsHeading.cs"); + + Assert.True(Directory.Exists(Path.Combine(blocksDir, "MBBlogSectionsHeadingBlocks"))); + Assert.True(File.Exists(blockFile)); + var content = File.ReadAllText(blockFile); + Assert.Contains($"using ContentstackModels.{_tempDir.Name}.BlogBlocks.MBBlogSectionsHeadingBlocks;", content); + } + + #endregion + + #region findRTEReference (direct) + + [Fact] + public void FindRTEReference_NullSchema_ReturnsFalse() + { + var generator = CreateGenerator(); + + var result = generator.findRTEReference(null); + + Assert.False(result); + } + + [Fact] + public void FindRTEReference_BlocksFieldWithNestedRteReference_ReturnsTrue() + { + var generator = CreateGenerator(); + var nestedSchema = new List { TextField("body", referenceTo: "some-rte-reference") }; + var blocks = new List { MakeContentType("heading", "Heading", nestedSchema) }; + var schema = new List { BlocksField("sections", "Sections", blocks) }; + + var result = generator.findRTEReference(schema); + + Assert.True(result); + } + + [Fact] + public void FindRTEReference_GroupFieldWithNullSchema_ReturnsFalse() + { + var generator = CreateGenerator(); + var schema = new List { GroupField("meta", "Meta", null) }; + + var result = generator.findRTEReference(schema); + + Assert.False(result); + } + + #endregion + + #region Nested groups + + [Fact] + public void CreateFile_SchemaWithGroupField_CreatesNestedGroupDirectoryAndFile() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog", new List { GroupField("meta", "Meta", new List { TextField("note") }) }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var groupDir = Path.Combine(_tempDir.FullName, "BlogGroup"); + var groupFile = Path.Combine(groupDir, "GroupBlogMeta.cs"); + + Assert.True(Directory.Exists(groupDir)); + Assert.True(File.Exists(groupFile)); + + var content = File.ReadAllText(groupFile); + Assert.Contains("[JsonPropertyName(\"note\")]", content); + Assert.Contains("public string Note { get; set; }", content); + } + + [Fact] + public void CreateFile_SchemaWithGroupField_AddsCorrectUsingDirectiveToTopLevelFile() + { + var generator = CreateGenerator(); + var contentType = MakeContentType("blog", "Blog", new List { GroupField("meta", "Meta", new List { TextField("note") }) }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.Contains($"using ContentstackModels.{_tempDir.Name}.BlogGroup;", content); + } + + [Fact] + public void CreateFile_GroupFieldContainingBlocksField_AddsModularUsingDirectiveInsideGroup() + { + var nestedBlocks = new List { MakeContentType("sub_heading", "Sub Heading") }; + var groupSchema = new List { BlocksField("sub_sections", "SubSections", nestedBlocks) }; + var contentType = MakeContentType("blog", "Blog", new List { GroupField("meta", "Meta", groupSchema) }); + var generator = CreateGenerator(); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var groupFile = Path.Combine(_tempDir.FullName, "BlogGroup", "GroupBlogMeta.cs"); + + Assert.True(File.Exists(groupFile)); + var content = File.ReadAllText(groupFile); + Assert.Contains($"using ContentstackModels.{_tempDir.Name}.BlogGroup.GroupBlogMetaBlocks;", content); + } + + #endregion + + #region Embedded-items interpolation bug regression + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void CreateFile_RteReferenceField_EmbeddedItemsLine_IsCorrectlyInterpolated_NotLiteralBraces(bool isNullable) + { + var generator = CreateGenerator(isNullable: isNullable); + var contentType = MakeContentType("blog", "Blog", new List { TextField("body", referenceTo: "some-rte-reference") }); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = ReadGenerated("Blog.cs"); + + Assert.DoesNotContain("{nullableString()}", content); + var suffix = isNullable ? "?" : ""; + Assert.Contains($"public Dictionary>{suffix} embeddedItems {{ get; set; }}", content); + } + + #endregion + + public void Dispose() + { + try { Directory.Delete(_tempDir.FullName, recursive: true); } catch { /* best-effort cleanup */ } + } + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorFieldTypeResolutionTests.cs b/contentstack.model.generator.tests/ModelGeneratorFieldTypeResolutionTests.cs new file mode 100644 index 0000000..436bf13 --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorFieldTypeResolutionTests.cs @@ -0,0 +1,280 @@ +using System; +using contentstack.model.generator.Model; +using Xunit; +using static contentstack.model.generator.tests.ModelGeneratorTestHelpers; + +namespace contentstack.model.generator.tests +{ + public class ModelGeneratorFieldTypeResolutionTests + { + #region Scalar field types + + [Fact] + public void GetDatatypeForField_TextField_ReturnsString() + { + var generator = CreateGenerator(); + var field = TextField("title"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("string", result); + } + + [Fact] + public void GetDatatypeForField_NumberField_ReturnsDouble() + { + var generator = CreateGenerator(); + var field = NumberField("views"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("double", result); + } + + [Fact] + public void GetDatatypeForField_BooleanField_ReturnsBool() + { + var generator = CreateGenerator(); + var field = BooleanField("published"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("bool", result); + } + + [Fact] + public void GetDatatypeForField_DateField_ReturnsDateTime() + { + var generator = CreateGenerator(); + var field = DateField("published_on"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("DateTime", result); + } + + [Fact] + public void GetDatatypeForField_FileField_ReturnsAsset() + { + var generator = CreateGenerator(); + var field = FileField("cover_image"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("Asset", result); + } + + [Fact] + public void GetDatatypeForField_LinkField_ReturnsContentstackLink() + { + var generator = CreateGenerator(); + var field = LinkTypeField("cta"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("ContentstackLink", result); + } + + [Fact] + public void GetDatatypeForField_JsonFieldWithJsonRte_ReturnsNode() + { + var generator = CreateGenerator(); + var field = JsonField("rich_text", isJsonRTE: true); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("Node", result); + } + + [Fact] + public void GetDatatypeForField_JsonFieldWithoutJsonRte_ReturnsDynamic() + { + var generator = CreateGenerator(); + var field = JsonField("raw_json", isJsonRTE: false); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("dynamic", result); + } + + [Fact] + public void GetDatatypeForField_UnknownDataType_ReturnsObject() + { + var generator = CreateGenerator(); + var field = UnknownTypeField("mystery"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("object", result); + } + + #endregion + + #region Blocks / group naming + + [Fact] + public void GetDatatypeForField_BlocksField_ReturnsModularBlockPrefixedName() + { + var generator = CreateGenerator(); + var field = BlocksField("sections", "Sections", new System.Collections.Generic.List()); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("MBBlogSections", result); + } + + [Fact] + public void GetDatatypeForField_GroupField_ReturnsGroupPrefixedName() + { + var generator = CreateGenerator(); + var field = GroupField("meta", "Meta", new System.Collections.Generic.List()); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("GroupBlogMeta", result); + } + + #endregion + + #region Reference / global_field resolution + + [Fact] + public void GetDatatypeForField_ReferenceSingle_Resolved_ReturnsClassName() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var field = ReferenceField("related", SingleRef("blog_post")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("BlogPost", result); + } + + [Fact] + public void GetDatatypeForField_ReferenceSingle_Unresolved_ReturnsObject() + { + var generator = CreateGenerator(); + var field = ReferenceField("related", SingleRef("missing_uid")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("object", result); + } + + [Fact] + public void GetDatatypeForField_ReferenceSingle_WithRefMultipleContentTypesTrue_ReturnsObject() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var field = ReferenceField("related", SingleRef("blog_post"), refMultipleContentType: true); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("object", result); + } + + [Fact] + public void GetDatatypeForField_ReferenceArrayOfOne_Resolved_ReturnsClassName() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var field = ReferenceField("related", MultiRef("blog_post")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("BlogPost", result); + } + + [Fact] + public void GetDatatypeForField_ReferenceArrayOfTwo_ReturnsObject() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var field = ReferenceField("related", MultiRef("blog_post", "author")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("object", result); + } + + [Fact] + public void GetDatatypeForField_GlobalField_UsesSameResolutionAsReference() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + var field = GlobalFieldField("related", SingleRef("blog_post")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("BlogPost", result); + } + + #endregion + + #region Multiplicity wrapping + + [Fact] + public void GetDatatypeForField_Reference_StackVersionAtThreshold_AlwaysWrapsInList() + { + var generator = CreateGenerator(stackVersion: DateTime.Parse("2019-04-04")); + var field = ReferenceField("related", SingleRef("missing_uid")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("List", result); + } + + [Fact] + public void GetDatatypeForField_Reference_StackVersionBeforeThreshold_SingleValue_ReturnsUnwrapped() + { + var generator = CreateGenerator(); + var field = ReferenceField("related", SingleRef("missing_uid")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("object", result); + } + + [Fact] + public void GetDatatypeForField_GlobalField_StackVersionAfterThreshold_IsMultipleFalse_ReturnsUnwrapped() + { + var generator = CreateGenerator(stackVersion: DateTime.Parse("2019-04-04")); + var field = GlobalFieldField("related", SingleRef("missing_uid")); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("object", result); + } + + [Fact] + public void GetDatatypeForField_AnyFieldType_WithRefMultipleTrue_WrapsInList() + { + var generator = CreateGenerator(); + var field = TextField("tags"); + field.FieldMetadata.RefMultiple = true; + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("List", result); + } + + [Fact] + public void GetDatatypeForField_AnyFieldType_WithIsMultipleTrue_WrapsInList() + { + var generator = CreateGenerator(); + var field = NumberField("scores", isMultiple: true); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("List", result); + } + + [Fact] + public void GetDatatypeForField_ScalarField_NoMultiplicityFlags_ReturnsUnwrapped() + { + var generator = CreateGenerator(); + var field = NumberField("score"); + + var result = generator.GetDatatypeForField(field, "Blog"); + + Assert.Equal("double", result); + } + + #endregion + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorNullableVariantTests.cs b/contentstack.model.generator.tests/ModelGeneratorNullableVariantTests.cs new file mode 100644 index 0000000..6b66c47 --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorNullableVariantTests.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using contentstack.model.generator.Model; +using Xunit; +using static contentstack.model.generator.tests.ModelGeneratorTestHelpers; + +namespace contentstack.model.generator.tests +{ + /// + /// Toggles IsNullable (the --is-nullable / -N CLI flag) across the code-generation + /// methods that append a nullable-reference suffix, and pins the one known asymmetry: + /// CreateEmbeddedObjectClass()'s Read/Write signatures never take a nullable suffix. + /// + public class ModelGeneratorNullableVariantTests : IDisposable + { + private readonly DirectoryInfo _tempDir; + + public ModelGeneratorNullableVariantTests() + { + _tempDir = Directory.CreateTempSubdirectory("cmg_nullable_tests_"); + } + + [Theory] + [InlineData(true, "?")] + [InlineData(false, "")] + public void AddParams_ScalarField_NullableSuffix_MatchesIsNullableSetting(bool isNullable, string suffix) + { + var generator = CreateGenerator(isNullable: isNullable); + var schema = new List { TextField("title") }; + var sb = new StringBuilder(); + + generator.AddParams("Blog", schema, sb); + var content = sb.ToString(); + + Assert.Contains($"public string{suffix} Title {{ get; set; }}", content); + } + + [Theory] + [InlineData(true, "?")] + [InlineData(false, "")] + public void CreateLinkClass_NullableSuffix_MatchesIsNullableSetting(bool isNullable, string suffix) + { + var generator = CreateGenerator(isNullable: isNullable); + + generator.CreateLinkClass("ContentstackModels", _tempDir); + var content = File.ReadAllText(Path.Combine(_tempDir.FullName, "ContentstackLink.cs")); + + Assert.Contains($"public string{suffix} Title {{ get; set; }}", content); + Assert.Contains($"public string{suffix} Href {{ get; set; }}", content); + } + + [Theory] + [InlineData(true, "?")] + [InlineData(false, "")] + public void CreateFile_UidAndContentTypeUid_NullableSuffix_MatchesIsNullableSetting(bool isNullable, string suffix) + { + var generator = CreateGenerator(isNullable: isNullable); + var contentType = MakeContentType("blog", "Blog", new List()); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = File.ReadAllText(Path.Combine(_tempDir.FullName, "Blog.cs")); + + Assert.Contains($"public string{suffix} Uid {{ get; set; }}", content); + Assert.Contains($"public string{suffix} ContentTypeUid {{ get; set; }}", content); + } + + [Theory] + [InlineData(true, "?")] + [InlineData(false, "")] + public void CreateFile_EmbeddedItemsProperty_NullableSuffix_MatchesIsNullableSetting(bool isNullable, string suffix) + { + var generator = CreateGenerator(isNullable: isNullable); + var schema = new List { TextField("body", referenceTo: "some-rte-reference") }; + var contentType = MakeContentType("blog", "Blog", schema); + + generator.CreateFile("Blog", "ContentstackModels", contentType, _tempDir); + var content = File.ReadAllText(Path.Combine(_tempDir.FullName, "Blog.cs")); + + Assert.Contains($"public Dictionary>{suffix} embeddedItems {{ get; set; }}", content); + } + + [Theory] + [InlineData(true, "?")] + [InlineData(false, "")] + public void CreateModularBlockConverter_ReadWriteSignatures_NullableSuffix_MatchesIsNullableSetting(bool isNullable, string suffix) + { + var generator = CreateGenerator(isNullable: isNullable); + var blockTypes = new Dictionary { { "heading", "MBTestHeading" } }; + + generator.CreateModularBlockConverter("ContentstackModels", "MBTest", blockTypes, _tempDir); + var content = File.ReadAllText(Path.Combine(_tempDir.FullName, "MBTestConverter.cs")); + + Assert.Contains($"public override MBTest{suffix} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)", content); + Assert.Contains($"public override void Write(Utf8JsonWriter writer, MBTest{suffix} value, JsonSerializerOptions options)", content); + } + + [Fact] + public void CreateEmbeddedObjectClass_ReadWriteSignatures_NeverHaveNullableSuffix() + { + var generator = CreateGenerator(isNullable: true); + + generator.CreateEmbeddedObjectClass("ContentstackModels", _tempDir); + var content = File.ReadAllText(Path.Combine(_tempDir.FullName, "IEmbeddedObjectConverter.cs")); + + Assert.Contains("public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)", content); + Assert.Contains("public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options)", content); + Assert.DoesNotContain("List?", content); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir.FullName, recursive: true); } catch { /* best-effort cleanup */ } + } + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorSupportingClassGenerationTests.cs b/contentstack.model.generator.tests/ModelGeneratorSupportingClassGenerationTests.cs new file mode 100644 index 0000000..db00462 --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorSupportingClassGenerationTests.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Xunit; +using static contentstack.model.generator.tests.ModelGeneratorTestHelpers; + +namespace contentstack.model.generator.tests +{ + /// + /// CreateLinkClass(), CreateEmbeddedObjectClass(), CreateModularBlockConverter(), + /// CreateHelperClass(), CreateStringHelperClass(), and CreateDisplayAttributeClass() + /// called directly (bypassing CreateFile()) - these are only invoked from OnExecute() + /// in production, so there is no other way to reach them in a unit test. + /// + public class ModelGeneratorSupportingClassGenerationTests : IDisposable + { + private readonly DirectoryInfo _tempDir; + + public ModelGeneratorSupportingClassGenerationTests() + { + _tempDir = Directory.CreateTempSubdirectory("cmg_supporting_tests_"); + } + + private string ReadGenerated(string fileName) => + File.ReadAllText(Path.Combine(_tempDir.FullName, fileName)); + + #region CreateLinkClass + + [Fact] + public void CreateLinkClass_WritesFileWithTitleAndHrefProperties() + { + var generator = CreateGenerator(); + + generator.CreateLinkClass("ContentstackModels", _tempDir); + var content = ReadGenerated("ContentstackLink.cs"); + + Assert.Contains("[JsonPropertyName(\"title\")]", content); + Assert.Contains("public string Title { get; set; }", content); + Assert.Contains("[JsonPropertyName(\"href\")]", content); + Assert.Contains("public string Href { get; set; }", content); + } + + [Fact] + public void CreateLinkClass_UsingDirectives_IncludeTemplateStartBlock() + { + var generator = CreateGenerator(); + + generator.CreateLinkClass("ContentstackModels", _tempDir); + var content = ReadGenerated("ContentstackLink.cs"); + + Assert.Contains("using Contentstack.Utils.Interfaces;", content); + Assert.Contains("using System.Text.Json.Serialization;", content); + } + + [Fact] + public void CreateLinkClass_ExistingFile_PromptYes_OverwritesFile() + { + var original = Console.In; + try + { + CreateGenerator(isNullable: false).CreateLinkClass("ContentstackModels", _tempDir); + + Console.SetIn(new StringReader("y\n")); + CreateGenerator(isNullable: true).CreateLinkClass("ContentstackModels", _tempDir); + + var content = ReadGenerated("ContentstackLink.cs"); + Assert.Contains("public string? Title { get; set; }", content); + } + finally + { + Console.SetIn(original); + } + } + + [Fact] + public void CreateLinkClass_ExistingFile_PromptNo_SkipsOverwrite() + { + var original = Console.In; + try + { + CreateGenerator(isNullable: false).CreateLinkClass("ContentstackModels", _tempDir); + + Console.SetIn(new StringReader("n\n")); + CreateGenerator(isNullable: true).CreateLinkClass("ContentstackModels", _tempDir); + + var content = ReadGenerated("ContentstackLink.cs"); + Assert.Contains("public string Title { get; set; }", content); + Assert.DoesNotContain("public string? Title { get; set; }", content); + } + finally + { + Console.SetIn(original); + } + } + + #endregion + + #region CreateHelperClass + + [Fact] + public void CreateHelperClass_WritesFileWithGetDescriptionAndFieldExists() + { + var generator = CreateGenerator(); + + generator.CreateHelperClass("ContentstackModels", _tempDir); + var content = ReadGenerated("ContentstackHelper.cs"); + + Assert.Contains("public static string GetDescription(Enum en)", content); + Assert.Contains("public static bool FieldExists(string fieldName, JsonObject jObject)", content); + Assert.Contains("return jObject[fieldName] != null;", content); + } + + #endregion + + #region CreateStringHelperClass + + [Fact] + public void CreateStringHelperClass_WritesFileWithToHtmlAndToListHtmlExtensions() + { + var generator = CreateGenerator(); + + generator.CreateStringHelperClass("ContentstackModels", _tempDir); + var content = ReadGenerated("ContentstackStringExtension.cs"); + + Assert.Contains("using Markdig;", content); + Assert.Contains("public static string ToHtml(this String str)", content); + Assert.Contains("public static List ToListHtml(this List str)", content); + } + + #endregion + + #region CreateDisplayAttributeClass + + [Fact] + public void CreateDisplayAttributeClass_WritesAttributeClassWithDisplayNameConstructor() + { + var generator = CreateGenerator(); + + generator.CreateDisplayAttributeClass("ContentstackModels", _tempDir); + var content = ReadGenerated("DisplayNameAttribute.cs"); + + Assert.Contains("[AttributeUsage(AttributeTargets.Field)]", content); + Assert.Contains("public partial class DisplayNameAttribute: Attribute", content); + Assert.Contains("public DisplayNameAttribute(string displayName)", content); + } + + #endregion + + #region CreateEmbeddedObjectClass + + [Fact] + public void CreateEmbeddedObjectClass_WritesConverterWithJsonConverterAttributeAndClassDeclaration() + { + var generator = CreateGenerator(); + + generator.CreateEmbeddedObjectClass("ContentstackModels", _tempDir); + var content = ReadGenerated("IEmbeddedObjectConverter.cs"); + + Assert.Contains("[CSJsonConverter(\"IEmbeddedObjectConverter\")]", content); + Assert.Contains("public partial class IEmbeddedObjectConverter : JsonConverter>", content); + } + + [Fact] + public void CreateEmbeddedObjectClass_PerContentType_EmitsIfBranchKeyedByContentTypeUid() + { + var generator = CreateGenerator(seedContentTypes: new[] + { + MakeContentType("blog_post", "Blog Post"), + MakeContentType("author", "Author") + }); + + generator.CreateEmbeddedObjectClass("ContentstackModels", _tempDir); + var content = ReadGenerated("IEmbeddedObjectConverter.cs"); + + Assert.Contains("if (ctUid == \"blog_post\")", content); + Assert.Contains("else if (ctUid == \"author\")", content); + Assert.Contains("JsonSerializer.Deserialize(obj.ToJsonString(), deserializeOptions);", content); + Assert.Contains("JsonSerializer.Deserialize(obj.ToJsonString(), deserializeOptions);", content); + } + + [Fact] + public void CreateEmbeddedObjectClass_ContentTypeWithEmptyUid_EmitsBranchForEmptyIdentifier() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("", "Untitled") }); + + generator.CreateEmbeddedObjectClass("ContentstackModels", _tempDir); + var content = ReadGenerated("IEmbeddedObjectConverter.cs"); + + Assert.Contains("if (ctUid == \"\")", content); + } + + [Fact] + public void CreateEmbeddedObjectClass_NoContentTypes_OmitsElseKeywordOnFirstBranch() + { + var generator = CreateGenerator(); + + generator.CreateEmbeddedObjectClass("ContentstackModels", _tempDir); + var content = ReadGenerated("IEmbeddedObjectConverter.cs"); + + Assert.Contains("if (ctUid == \"sys_assets\")", content); + Assert.DoesNotContain("else if (ctUid == \"sys_assets\")", content); + } + + [Fact] + public void CreateEmbeddedObjectClass_AlwaysEmitsSysAssetsFallbackBranch() + { + var generator = CreateGenerator(seedContentTypes: new[] { MakeContentType("blog_post", "Blog Post") }); + + generator.CreateEmbeddedObjectClass("ContentstackModels", _tempDir); + var content = ReadGenerated("IEmbeddedObjectConverter.cs"); + + Assert.Contains("else if (ctUid == \"sys_assets\")", content); + Assert.Contains("JsonSerializer.Deserialize(obj.ToJsonString(), deserializeOptions);", content); + } + + #endregion + + #region CreateModularBlockConverter + + [Fact] + public void CreateModularBlockConverter_WritesConverterClassDeclaration() + { + var generator = CreateGenerator(); + var blockTypes = new Dictionary { { "heading", "MBTestHeading" } }; + + generator.CreateModularBlockConverter("ContentstackModels", "MBTest", blockTypes, _tempDir); + var content = ReadGenerated("MBTestConverter.cs"); + + Assert.Contains("[CSJsonConverter(\"MBTestConverter\")]", content); + Assert.Contains("public partial class MBTestConverter : JsonConverter", content); + } + + [Fact] + public void CreateModularBlockConverter_PerBlockType_EmitsFieldExistsCheckAndBlockTypeAssignment() + { + var generator = CreateGenerator(); + var blockTypes = new Dictionary + { + { "heading", "MBTestHeading" }, + { "quote", "MBTestQuote" } + }; + + generator.CreateModularBlockConverter("ContentstackModels", "MBTest", blockTypes, _tempDir); + var content = ReadGenerated("MBTestConverter.cs"); + + Assert.Contains("if (ContentstackHelper.FieldExists(ContentstackHelper.GetDescription(MBTestEnum.Heading), jObject))", content); + Assert.Contains("block.BlockType = MBTestEnum.Heading;", content); + Assert.Contains("if (ContentstackHelper.FieldExists(ContentstackHelper.GetDescription(MBTestEnum.Quote), jObject))", content); + Assert.Contains("block.BlockType = MBTestEnum.Quote;", content); + } + + [Fact] + public void CreateModularBlockConverter_EmptyBlockTypes_EmitsOnlyFallbackReturn() + { + var generator = CreateGenerator(); + var blockTypes = new Dictionary(); + + generator.CreateModularBlockConverter("ContentstackModels", "MBTest", blockTypes, _tempDir); + var content = ReadGenerated("MBTestConverter.cs"); + + Assert.Contains("return new MBTest();", content); + Assert.DoesNotContain("ContentstackHelper.FieldExists", content); + } + + [Fact] + public void CreateModularBlockConverter_Write_WrapsSerializeInNullCheck() + { + var generator = CreateGenerator(); + var blockTypes = new Dictionary { { "heading", "MBTestHeading" } }; + + generator.CreateModularBlockConverter("ContentstackModels", "MBTest", blockTypes, _tempDir); + var content = ReadGenerated("MBTestConverter.cs"); + + Assert.Contains("if (value != null)", content); + Assert.Contains("JsonSerializer.Serialize(writer, value, options);", content); + } + + #endregion + + public void Dispose() + { + try { Directory.Delete(_tempDir.FullName, recursive: true); } catch { /* best-effort cleanup */ } + } + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorTestHelpers.cs b/contentstack.model.generator.tests/ModelGeneratorTestHelpers.cs new file mode 100644 index 0000000..71027c9 --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorTestHelpers.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using contentstack.model.generator; +using contentstack.model.generator.Model; +using Contentstack.Model.Generator.Model; + +namespace contentstack.model.generator.tests +{ + /// + /// Shared fixture builders for ModelGenerator code-generation tests. + /// Not a test class - contains no [Fact]/[Theory] methods. + /// + internal static class ModelGeneratorTestHelpers + { + // Older than the "Apr, 04 2019" threshold GetDatatypeForField checks for "reference" fields. + private static readonly DateTime PreReferenceVersionThreshold = DateTime.Parse("2018-01-01"); + + internal static ModelGenerator CreateGenerator( + bool isNullable = false, + DateTime? stackVersion = null, + IEnumerable? seedContentTypes = null) + { + var generator = new ModelGenerator + { + IsNullable = isNullable, + stack = new StackResponse + { + Settings = new StackSettings { version = stackVersion ?? PreReferenceVersionThreshold } + } + }; + + if (seedContentTypes != null) + { + generator._contentTypes.AddRange(seedContentTypes); + } + + return generator; + } + + internal static MetaData Meta( + bool refMultiple = false, + bool refMultipleContentType = false, + bool isMarkdown = false, + bool isJsonRTE = false) + { + return new MetaData + { + RefMultiple = refMultiple, + RefMultipleContentType = refMultipleContentType, + IsMarkdown = isMarkdown, + IsJsonRTE = isJsonRTE + }; + } + + internal static Field TextField(string uid, string? displayName = null, bool isMultiple = false, bool isMarkdown = false, object? referenceTo = null) + { + return new Field + { + Uid = uid, + DisplayName = displayName ?? uid, + DataType = "text", + IsMultiple = isMultiple, + ReferenceTo = referenceTo, + FieldMetadata = Meta(isMarkdown: isMarkdown) + }; + } + + internal static Field NumberField(string uid, bool isMultiple = false, bool refMultiple = false) + { + return new Field + { + Uid = uid, + DisplayName = uid, + DataType = "number", + IsMultiple = isMultiple, + FieldMetadata = Meta(refMultiple: refMultiple) + }; + } + + internal static Field BooleanField(string uid) + { + return new Field { Uid = uid, DisplayName = uid, DataType = "boolean", FieldMetadata = Meta() }; + } + + internal static Field DateField(string uid) + { + return new Field { Uid = uid, DisplayName = uid, DataType = "isodate", FieldMetadata = Meta() }; + } + + internal static Field FileField(string uid, bool isMultiple = false) + { + return new Field { Uid = uid, DisplayName = uid, DataType = "file", IsMultiple = isMultiple, FieldMetadata = Meta() }; + } + + internal static Field JsonField(string uid, bool isJsonRTE) + { + return new Field { Uid = uid, DisplayName = uid, DataType = "json", FieldMetadata = Meta(isJsonRTE: isJsonRTE) }; + } + + internal static Field LinkTypeField(string uid) + { + return new Field { Uid = uid, DisplayName = uid, DataType = "link", FieldMetadata = Meta() }; + } + + internal static Field UnknownTypeField(string uid) + { + return new Field { Uid = uid, DisplayName = uid, DataType = "some_unmapped_type", FieldMetadata = Meta() }; + } + + internal static Field ReferenceField(string uid, object referenceTo, bool refMultiple = false, bool refMultipleContentType = false, bool isMultiple = false) + { + return new Field + { + Uid = uid, + DisplayName = uid, + DataType = "reference", + IsMultiple = isMultiple, + ReferenceTo = referenceTo, + FieldMetadata = Meta(refMultiple: refMultiple, refMultipleContentType: refMultipleContentType) + }; + } + + internal static Field GlobalFieldField(string uid, object referenceTo, bool refMultiple = false, bool refMultipleContentType = false, bool isMultiple = false) + { + var field = ReferenceField(uid, referenceTo, refMultiple, refMultipleContentType, isMultiple); + field.DataType = "global_field"; + return field; + } + + internal static Field GroupField(string uid, string displayName, List? schema) + { + return new Field { Uid = uid, DisplayName = displayName, DataType = "group", Schema = schema, FieldMetadata = Meta() }; + } + + internal static Field BlocksField(string uid, string displayName, List blocks) + { + return new Field { Uid = uid, DisplayName = displayName, DataType = "blocks", Blocks = blocks, FieldMetadata = Meta() }; + } + + internal static Contenttype MakeContentType(string uid, string title, List? schema = null, string? referenceTo = null) + { + return new Contenttype + { + Uid = uid, + Title = title, + Schema = schema ?? new List(), + ReferenceTo = referenceTo + }; + } + + // Field.ReferenceTo is typed `object` but production code pattern-matches `is JsonElement` - + // a plain string/string[] would silently fall through to "object", so build real JsonElements. + internal static object SingleRef(string uid) => JsonSerializer.SerializeToElement(uid); + + internal static object MultiRef(params string[] uids) => JsonSerializer.SerializeToElement(uids); + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorTests.cs b/contentstack.model.generator.tests/ModelGeneratorTests.cs index cb60ab6..4c58488 100644 --- a/contentstack.model.generator.tests/ModelGeneratorTests.cs +++ b/contentstack.model.generator.tests/ModelGeneratorTests.cs @@ -78,11 +78,25 @@ public void OAuthScopes_ShouldBeOptional() [Fact] public void OAuthClientSecret_ShouldBeOptional() { - + var modelGenerator = new ModelGenerator(); Assert.Null(modelGenerator.OAuthClientSecret); } + [Fact] + public void Path_DefaultValue_IsNull() + { + var modelGenerator = new ModelGenerator(); + Assert.Null(modelGenerator.Path); + } + + [Fact] + public void Version_DefaultValue_IsFalse() + { + var modelGenerator = new ModelGenerator(); + Assert.False(modelGenerator.Version); + } + #endregion #region OAuth Scopes Parsing Tests diff --git a/contentstack.model.generator.tests/OAuthModelTests.cs b/contentstack.model.generator.tests/OAuthModelTests.cs new file mode 100644 index 0000000..6b854ca --- /dev/null +++ b/contentstack.model.generator.tests/OAuthModelTests.cs @@ -0,0 +1,355 @@ +using System; +using System.Text.Json; +using Contentstack.Model.Generator.Model; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class OAuthModelTests + { + #region OAuthUser / OAuthAppAuthorizationData / OAuthAppAuthorizationResponse + + [Fact] + public void OAuthUser_Uid_CanBeSetAndRetrieved() + { + var user = new OAuthUser { Uid = "user_123" }; + + Assert.Equal("user_123", user.Uid); + } + + [Fact] + public void OAuthAppAuthorizationData_PropertiesCanBeSetAndRetrieved() + { + var data = new OAuthAppAuthorizationData + { + AuthorizationUid = "auth_uid_123", + User = new OAuthUser { Uid = "user_123" } + }; + + Assert.Equal("auth_uid_123", data.AuthorizationUid); + Assert.Equal("user_123", data.User.Uid); + } + + [Fact] + public void OAuthAppAuthorizationResponse_DeserializesFromJson() + { + var json = "{\"data\":[{\"authorization_uid\":\"auth_1\",\"user\":{\"uid\":\"user_1\"}}]}"; + + var response = JsonSerializer.Deserialize(json); + + Assert.NotNull(response); + Assert.Single(response.Data); + Assert.Equal("auth_1", response.Data[0].AuthorizationUid); + Assert.Equal("user_1", response.Data[0].User.Uid); + } + + #endregion + + #region OAuthOptions + + [Fact] + public void OAuthOptions_Defaults_MatchContentstackAppDefaults() + { + var options = new OAuthOptions(); + + Assert.Equal("6400aa06db64de001a31c8a9", options.AppId); + Assert.Equal("Ie0FEfTzlfAHL4xM", options.ClientId); + Assert.Equal("http://localhost:8184", options.RedirectUri); + Assert.Equal("code", options.ResponseType); + Assert.Null(options.ClientSecret); + Assert.Null(options.Scope); + } + + [Fact] + public void UsePkce_WithNoClientSecret_IsTrue() + { + var options = new OAuthOptions(); + + Assert.True(options.UsePkce); + } + + [Fact] + public void UsePkce_WithClientSecret_IsFalse() + { + var options = new OAuthOptions { ClientSecret = "secret" }; + + Assert.False(options.UsePkce); + } + + [Fact] + public void IsValid_WithDefaultOptions_ReturnsTrue() + { + var options = new OAuthOptions(); + + Assert.True(options.IsValid()); + } + + [Fact] + public void IsValid_WithMissingAppId_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { AppId = "" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("AppId is required for OAuth configuration.", errorMessage); + } + + [Fact] + public void IsValid_WithMissingClientId_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { ClientId = "" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("ClientId is required for OAuth configuration.", errorMessage); + } + + [Fact] + public void IsValid_WithMissingRedirectUri_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { RedirectUri = "" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("RedirectUri is required for OAuth configuration.", errorMessage); + } + + [Fact] + public void IsValid_WithRelativeRedirectUri_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { RedirectUri = "not-a-valid-uri" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("RedirectUri must be a valid absolute URI.", errorMessage); + } + + [Fact] + public void IsValid_WithNonHttpRedirectUriScheme_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { RedirectUri = "ftp://localhost:8184" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("RedirectUri must use http or https scheme.", errorMessage); + } + + [Fact] + public void IsValid_WithMissingResponseType_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { ResponseType = "" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("ResponseType is required for OAuth configuration.", errorMessage); + } + + [Fact] + public void IsValid_WithNonCodeResponseType_ReturnsFalseWithMessage() + { + var options = new OAuthOptions { ResponseType = "token" }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal("ResponseType must be 'code' for authorization code flow.", errorMessage); + } + + [Fact] + public void IsValid_NonPkceWithWhitespaceOnlyClientSecret_ReturnsFalseWithMessage() + { + // ClientSecret = " " is not IsNullOrEmpty (so UsePkce is false / non-PKCE flow), + // but IsNullOrWhiteSpace is true, so this reaches the traditional-flow guard. + var options = new OAuthOptions { ClientSecret = " " }; + + var isValid = options.IsValid(out var errorMessage); + + Assert.False(isValid); + Assert.Equal( + "ClientSecret is required for traditional OAuth flow. Use PKCE flow (leave ClientSecret empty) for public clients.", + errorMessage); + } + + [Fact] + public void IsValid_NonPkceWithClientSecret_ReturnsTrue() + { + var options = new OAuthOptions { ClientSecret = "a-real-secret" }; + + Assert.True(options.IsValid()); + } + + [Fact] + public void Validate_WithValidOptions_DoesNotThrow() + { + var options = new OAuthOptions(); + + var exception = Record.Exception(() => options.Validate()); + + Assert.Null(exception); + } + + [Fact] + public void Validate_WithInvalidOptions_ThrowsOAuthConfigurationException() + { + var options = new OAuthOptions { AppId = "" }; + + var exception = Assert.Throws(() => options.Validate()); + Assert.Equal("AppId is required for OAuth configuration.", exception.Message); + } + + [Fact] + public void ToString_IncludesKeyConfigurationValues() + { + var options = new OAuthOptions { Scope = new[] { "read", "write" } }; + + var result = options.ToString(); + + Assert.Contains("AppId=6400aa06db64de001a31c8a9", result); + Assert.Contains("UsePkce=True", result); + Assert.Contains("HasScope=True", result); + } + + #endregion + + #region OAuthResponse + + [Fact] + public void OAuthResponse_DeserializesFromJson() + { + var json = "{\"access_token\":\"tok\",\"refresh_token\":\"ref\",\"expires_in\":3600," + + "\"organization_uid\":\"org_1\",\"user_uid\":\"user_1\"}"; + + var response = JsonSerializer.Deserialize(json); + + Assert.Equal("tok", response.AccessToken); + Assert.Equal("ref", response.RefreshToken); + Assert.Equal(3600, response.ExpiresIn); + Assert.Equal("org_1", response.OrganizationUid); + Assert.Equal("user_1", response.UserUid); + } + + #endregion + + #region OAuthTokens + + [Fact] + public void IsExpired_WithDefaultExpiresAt_IsTrue() + { + var tokens = new OAuthTokens(); + + Assert.True(tokens.IsExpired); + } + + [Fact] + public void IsExpired_WithPastExpiresAt_IsTrue() + { + var tokens = new OAuthTokens { ExpiresAt = DateTime.UtcNow.AddMinutes(-10) }; + + Assert.True(tokens.IsExpired); + } + + [Fact] + public void IsExpired_WithFutureExpiresAt_IsFalse() + { + var tokens = new OAuthTokens { ExpiresAt = DateTime.UtcNow.AddHours(1) }; + + Assert.False(tokens.IsExpired); + } + + [Fact] + public void NeedsRefresh_WithDefaultExpiresAt_IsTrue() + { + var tokens = new OAuthTokens(); + + Assert.True(tokens.NeedsRefresh); + } + + [Fact] + public void NeedsRefresh_WithinFiveMinutesOfExpiry_IsTrue() + { + var tokens = new OAuthTokens { ExpiresAt = DateTime.UtcNow.AddMinutes(2) }; + + Assert.True(tokens.NeedsRefresh); + } + + [Fact] + public void NeedsRefresh_WellBeforeExpiry_IsFalse() + { + var tokens = new OAuthTokens { ExpiresAt = DateTime.UtcNow.AddHours(1) }; + + Assert.False(tokens.NeedsRefresh); + } + + [Fact] + public void NeedsRefresh_WhenSubtractingFiveMinutesUnderflowsDateTimeRange_IsTrue() + { + var tokens = new OAuthTokens { ExpiresAt = DateTime.MinValue.AddSeconds(1) }; + + Assert.True(tokens.NeedsRefresh); + } + + [Fact] + public void IsValid_WithAccessTokenAndNotExpired_IsTrue() + { + var tokens = new OAuthTokens { AccessToken = "tok", ExpiresAt = DateTime.UtcNow.AddHours(1) }; + + Assert.True(tokens.IsValid); + } + + [Fact] + public void IsValid_WithNoAccessToken_IsFalse() + { + var tokens = new OAuthTokens { AccessToken = null, ExpiresAt = DateTime.UtcNow.AddHours(1) }; + + Assert.False(tokens.IsValid); + } + + [Fact] + public void IsValid_WhenExpired_IsFalse() + { + var tokens = new OAuthTokens { AccessToken = "tok", ExpiresAt = DateTime.UtcNow.AddMinutes(-1) }; + + Assert.False(tokens.IsValid); + } + + [Fact] + public void OtherProperties_CanBeSetAndRetrieved() + { + var tokens = new OAuthTokens + { + RefreshToken = "refresh", + OrganizationUid = "org_1", + UserUid = "user_1", + ClientId = "client_1", + AppId = "app_1" + }; + + Assert.Equal("refresh", tokens.RefreshToken); + Assert.Equal("org_1", tokens.OrganizationUid); + Assert.Equal("user_1", tokens.UserUid); + Assert.Equal("client_1", tokens.ClientId); + Assert.Equal("app_1", tokens.AppId); + } + + #endregion + + #region OAuthConfigurationException + + [Fact] + public void OAuthConfigurationException_SetsMessage() + { + var exception = new OAuthConfigurationException("bad config"); + + Assert.Equal("bad config", exception.Message); + Assert.IsAssignableFrom(exception); + } + + #endregion + } +} diff --git a/contentstack.model.generator.tests/OAuthServiceTests.cs b/contentstack.model.generator.tests/OAuthServiceTests.cs index 356370e..fcd69cd 100644 --- a/contentstack.model.generator.tests/OAuthServiceTests.cs +++ b/contentstack.model.generator.tests/OAuthServiceTests.cs @@ -5,7 +5,6 @@ using contentstack.CMA; using contentstack.CMA.OAuth; using System.Net.Http; -using Newtonsoft.Json; using System.Collections.Generic; namespace contentstack.model.generator.tests diff --git a/contentstack.model.generator.tests/OAuthTokenExchangeTests.cs b/contentstack.model.generator.tests/OAuthTokenExchangeTests.cs index d16a597..5b8fbb3 100644 --- a/contentstack.model.generator.tests/OAuthTokenExchangeTests.cs +++ b/contentstack.model.generator.tests/OAuthTokenExchangeTests.cs @@ -1,4 +1,5 @@ using System; +using System.Text.Json; using contentstack.CMA.OAuth; using Xunit; @@ -61,8 +62,9 @@ public void OAuthTokenResponse_ShouldBeSerializable() }; - var json = Newtonsoft.Json.JsonConvert.SerializeObject(response); - var deserializedResponse = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + var json = JsonSerializer.Serialize(response); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var deserializedResponse = JsonSerializer.Deserialize(json, options); Assert.NotNull(deserializedResponse); diff --git a/contentstack.model.generator.tests/ProgramTests.cs b/contentstack.model.generator.tests/ProgramTests.cs new file mode 100644 index 0000000..6b78555 --- /dev/null +++ b/contentstack.model.generator.tests/ProgramTests.cs @@ -0,0 +1,38 @@ +using System.Threading.Tasks; +using contentstack.model.generator; +using Xunit; + +namespace contentstack.model.generator.tests +{ + /// + /// Program.Main() is the real CLI entry point. These tests only exercise + /// argument-validation paths that return before any network call is made + /// (missing --api-key / missing --authtoken), so they run safely offline. + /// + public class ProgramTests + { + [Fact] + public async Task Main_WithNoArguments_ReturnsNonZeroExitCode() + { + var result = await Program.Main(new string[0]); + + Assert.NotEqual(Program.OK, result); + } + + [Fact] + public async Task Main_WithApiKeyButNoAuthTokenOrOAuth_ReturnsErrorCode() + { + var result = await Program.Main(new[] { "--api-key", "test_key" }); + + Assert.Equal(Program.ERROR, result); + } + + [Fact] + public void ExitCodeConstants_HaveExpectedValues() + { + Assert.Equal(0, Program.OK); + Assert.Equal(1, Program.ERROR); + Assert.Equal(2, Program.EXCEPTION); + } + } +} diff --git a/contentstack.model.generator.tests/StackResponseModelTests.cs b/contentstack.model.generator.tests/StackResponseModelTests.cs new file mode 100644 index 0000000..288042e --- /dev/null +++ b/contentstack.model.generator.tests/StackResponseModelTests.cs @@ -0,0 +1,38 @@ +using System; +using Contentstack.Model.Generator.Model; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class StackResponseModelTests + { + [Fact] + public void Properties_CanBeSetAndRetrieved() + { + var stack = new StackResponse + { + APIKey = "test_api_key", + Name = "My Stack", + MasterLocale = "en-us", + Uid = "stack_uid_123", + Description = "A test stack" + }; + + Assert.Equal("test_api_key", stack.APIKey); + Assert.Equal("My Stack", stack.Name); + Assert.Equal("en-us", stack.MasterLocale); + Assert.Equal("stack_uid_123", stack.Uid); + Assert.Equal("A test stack", stack.Description); + } + + [Fact] + public void Settings_Version_CanBeSetAndRetrieved() + { + var version = new DateTime(2019, 4, 4); + + var settings = new StackSettings { version = version }; + + Assert.Equal(version, settings.version); + } + } +} diff --git a/contentstack.model.generator.tests/contentstack.model.generator.tests.csproj b/contentstack.model.generator.tests/contentstack.model.generator.tests.csproj index 76635fa..fc5e8cf 100644 --- a/contentstack.model.generator.tests/contentstack.model.generator.tests.csproj +++ b/contentstack.model.generator.tests/contentstack.model.generator.tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net10.0 enable enable false @@ -9,7 +9,7 @@ - + diff --git a/contentstack.model.generator/CMA/ContentStackError.cs b/contentstack.model.generator/CMA/ContentStackError.cs index e831ad3..bdcc9cd 100644 --- a/contentstack.model.generator/CMA/ContentStackError.cs +++ b/contentstack.model.generator/CMA/ContentStackError.cs @@ -1,9 +1,8 @@ -using Newtonsoft.Json; -using System; +using System; using System.Collections.Generic; using System.Net; -using System.Runtime.Serialization; - +using System.Text.Json.Serialization; + namespace contentstack.CMA { /// @@ -13,7 +12,7 @@ namespace contentstack.CMA public class ContentstackException : Exception { #region Private Variables - private string _ErrorMessage = string.Empty; + private string _ErrorMessage = string.Empty; #endregion @@ -31,7 +30,7 @@ public class ContentstackException : Exception /// /// This is error message. /// - [JsonProperty("error_message")] + [JsonPropertyName("error_message")] public string ErrorMessage { get @@ -48,13 +47,13 @@ public string ErrorMessage /// /// This is error code. /// - [JsonProperty("error_code")] + [JsonPropertyName("error_code")] public int ErrorCode { get; set; } /// /// Set of errors in detail. /// - [JsonProperty("errors")] + [JsonPropertyName("errors")] public Dictionary Errors { get; set; } #endregion @@ -85,21 +84,13 @@ public ContentstackException(Exception exception) : base(exception.Message, exception.InnerException) { this.ErrorMessage = exception.Message; - } - - protected ContentstackException(SerializationInfo info, StreamingContext context) : base(info, context) - { - } - - public ContentstackException(string message, Exception innerException) : base(message, innerException) - { - } - public override void GetObjectData(SerializationInfo info, StreamingContext context) - { - base.GetObjectData(info, context); - } + } + + public ContentstackException(string message, Exception innerException) : base(message, innerException) + { + } #endregion - + } } diff --git a/contentstack.model.generator/CMA/ContentstackClient.cs b/contentstack.model.generator/CMA/ContentstackClient.cs index 96d3041..b5feb70 100644 --- a/contentstack.model.generator/CMA/ContentstackClient.cs +++ b/contentstack.model.generator/CMA/ContentstackClient.cs @@ -1,12 +1,12 @@ using System; using System.Collections.Generic; -using Newtonsoft.Json.Linq; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using System.Linq; using System.Threading.Tasks; using System.Net; using System.IO; -using System.Collections; using Contentstack.Model.Generator.Model; using contentstack.model.generator.Model; @@ -14,7 +14,13 @@ namespace contentstack.CMA { public class ContentstackClient { - public JsonSerializerSettings SerializerSettings { get; set; } = new JsonSerializerSettings(); + public JsonSerializerOptions SerializerOptions { get; set; } = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + AllowTrailingCommas = true, + NumberHandling = JsonNumberHandling.AllowReadingFromString, + }; #region Internal Variables @@ -131,19 +137,18 @@ internal static ContentstackException GetContentstackException(Exception ex) using (var reader = new StreamReader(stream)) { errorMessage = reader.ReadToEnd(); - JObject data = JObject.Parse(errorMessage.Replace("\r\n", "")); - - JToken token = data["error_code"]; - if (token != null) - errorCode = token.Value(); + var node = JsonNode.Parse(errorMessage.Replace("\r\n", "")); + if (node is JsonObject data) + { + if (data["error_code"] is JsonValue ec) + errorCode = ec.GetValue(); - token = data["error_message"]; - if (token != null) - errorMessage = token.Value(); + if (data["error_message"] is JsonValue em) + errorMessage = em.GetValue(); - token = data["errors"]; - if (token != null) - errors = token.ToObject>(); + if (data["errors"] is JsonObject errs) + errors = JsonSerializer.Deserialize>(errs.ToJsonString()); + } var response = exResp as HttpWebResponse; if (response != null) @@ -206,11 +211,8 @@ public async Task GetStack() { HttpRequestHandler RequestHandler = new HttpRequestHandler(); var outputResult = await RequestHandler.ProcessRequest(_StackUrl, headers, mainJson); - JObject data = JsonConvert.DeserializeObject(outputResult.Replace("\r\n", ""), this.SerializerSettings); - SerializerSettings.DateFormatString = "yyyy-MM-dd"; - var stack = data["stack"]; - var stackJson = JsonConvert.SerializeObject(stack); - return JsonConvert.DeserializeObject(stackJson, this.SerializerSettings); + var root = JsonNode.Parse(outputResult.Replace("\r\n", ""))!.AsObject(); + return JsonSerializer.Deserialize(root["stack"]!.ToJsonString(), this.SerializerOptions); } catch (Exception ex) { @@ -245,13 +247,11 @@ public async Task GetContentTypes(int skip = 0) { HttpRequestHandler RequestHandler = new HttpRequestHandler(); var outputResult = await RequestHandler.ProcessRequest(_Url, headers, mainJson); - JObject data = JsonConvert.DeserializeObject(outputResult.Replace("\r\n", ""), this.SerializerSettings); - IList contentTypes = (IList)data["content_types"]; + var root = JsonNode.Parse(outputResult.Replace("\r\n", ""))!.AsObject(); ContentstackResponse contentstackResponse = new ContentstackResponse(); - var ContentTypeJson = JsonConvert.SerializeObject(contentTypes); - contentstackResponse.listContentTypes = JsonConvert.DeserializeObject>(ContentTypeJson); - if (data["count"] != null) { - contentstackResponse.Count = (int)data["count"]; + contentstackResponse.listContentTypes = JsonSerializer.Deserialize>(root["content_types"]!.ToJsonString(), this.SerializerOptions); + if (root["count"] is JsonValue count) { + contentstackResponse.Count = count.GetValue(); } return contentstackResponse; } @@ -286,13 +286,11 @@ public async Task GetGlobalFields(int skip = 0) { HttpRequestHandler RequestHandler = new HttpRequestHandler(); var outputResult = await RequestHandler.ProcessRequest(_GlobalFieldsUrl, headers, mainJson); - JObject data = JsonConvert.DeserializeObject(outputResult.Replace("\r\n", ""), this.SerializerSettings); - IList globalFields = (IList)data["global_fields"]; + var root = JsonNode.Parse(outputResult.Replace("\r\n", ""))!.AsObject(); ContentstackResponse contentstackResponse = new ContentstackResponse(); - var ContentTypeJson = JsonConvert.SerializeObject(globalFields); - contentstackResponse.listContentTypes = JsonConvert.DeserializeObject>(ContentTypeJson); - if (data["count"] != null) { - contentstackResponse.Count = (int)data["count"]; + contentstackResponse.listContentTypes = JsonSerializer.Deserialize>(root["global_fields"]!.ToJsonString(), this.SerializerOptions); + if (root["count"] is JsonValue count) { + contentstackResponse.Count = count.GetValue(); } return contentstackResponse; } @@ -303,7 +301,7 @@ public async Task GetGlobalFields(int skip = 0) } } - private Dictionary GetHeader(Dictionary localHeader) + internal Dictionary GetHeader(Dictionary localHeader) { Dictionary mainHeader = _StackHeaders; Dictionary classHeaders = new Dictionary(); diff --git a/contentstack.model.generator/CMA/HTTPRequestHandler.cs b/contentstack.model.generator/CMA/HTTPRequestHandler.cs index 9214d28..a70cfd5 100644 --- a/contentstack.model.generator/CMA/HTTPRequestHandler.cs +++ b/contentstack.model.generator/CMA/HTTPRequestHandler.cs @@ -1,5 +1,5 @@ -using Newtonsoft.Json; -using System; +using System; +using System.Text.Json; using System.Collections.Generic; using System.IO; using System.Linq; @@ -14,12 +14,10 @@ namespace contentstack.CMA internal class HttpRequestHandler { private readonly HttpClient _httpClient; - private readonly JsonSerializer _serializer; public HttpRequestHandler() { _httpClient = new HttpClient(); - _serializer = new JsonSerializer(); } public async Task ProcessRequest(string Url, Dictionary Headers, Dictionary BodyJson, string FileName = null) @@ -37,7 +35,7 @@ public async Task ProcessRequest(string Url, Dictionary return value; } else if (kvp.Value is Dictionary) - value = JsonConvert.SerializeObject(kvp.Value); + value = JsonSerializer.Serialize(kvp.Value); else return String.Format("{0}={1}", kvp.Key, kvp.Value); @@ -99,8 +97,8 @@ public async Task ProcessRequest(string Url, Dictionary var errorContent = await response.Content.ReadAsStringAsync(); throw new HttpRequestException($"HTTP request failed with status code: {response.StatusCode} - {errorContent}"); } - } - catch (Exception ex) + } + catch (Exception) { throw; } diff --git a/contentstack.model.generator/CMA/Http/ContentstackHttpRequest.cs b/contentstack.model.generator/CMA/Http/ContentstackHttpRequest.cs index 16f19fe..1aff203 100644 --- a/contentstack.model.generator/CMA/Http/ContentstackHttpRequest.cs +++ b/contentstack.model.generator/CMA/Http/ContentstackHttpRequest.cs @@ -5,7 +5,7 @@ using System.Collections.Generic; using System.Net; using System.IO; -using Newtonsoft.Json; +using System.Text.Json; namespace contentstack.CMA.Http { @@ -15,7 +15,7 @@ internal class ContentstackHttpRequest : IHttpRequest private bool _disposed = false; private readonly HttpClient _httpClient; private readonly HttpRequestMessage _request; - private readonly JsonSerializer _serializer; + private readonly JsonSerializerOptions _options; #endregion #region Public @@ -52,10 +52,10 @@ public HttpRequestMessage Request #endregion #region Constructor - internal ContentstackHttpRequest(HttpClient httpClient, JsonSerializer serializer) + internal ContentstackHttpRequest(HttpClient httpClient, JsonSerializerOptions options) { _httpClient = httpClient; - _serializer = serializer; + _options = options; _request = new HttpRequestMessage(); } #endregion @@ -124,14 +124,14 @@ public async Task GetResponseAsync() if (responseMessage.StatusCode >= HttpStatusCode.Ambiguous && responseMessage.StatusCode < HttpStatusCode.BadRequest) - return new ContentstackResponse(responseMessage, _serializer); + return new ContentstackResponse(responseMessage, _options); if (!responseMessage.IsSuccessStatusCode) { throw new HttpRequestException($"HTTP request failed with status code: {responseMessage.StatusCode}"); } - return new ContentstackResponse(responseMessage, _serializer); + return new ContentstackResponse(responseMessage, _options); } catch (HttpRequestException httpException) { diff --git a/contentstack.model.generator/CMA/Http/ContentstackResponse.cs b/contentstack.model.generator/CMA/Http/ContentstackResponse.cs index 4a4605e..ca0d813 100644 --- a/contentstack.model.generator/CMA/Http/ContentstackResponse.cs +++ b/contentstack.model.generator/CMA/Http/ContentstackResponse.cs @@ -4,8 +4,8 @@ using System.Net; using System.Net.Http; using System.Net.Http.Headers; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; namespace contentstack.CMA.Http { @@ -20,7 +20,7 @@ public class ContentstackResponse : IResponse, IDisposable Dictionary _headers; HashSet _headerNamesSet; private readonly HttpResponseMessage _response; - private readonly JsonSerializer _serializer; + private readonly JsonSerializerOptions _options; #region Public /// @@ -126,10 +126,10 @@ private void CopyHeaderValues(HttpResponseMessage response) } #endregion - internal ContentstackResponse(HttpResponseMessage response, JsonSerializer serializer) + internal ContentstackResponse(HttpResponseMessage response, JsonSerializerOptions options) { _response = response; - _serializer = serializer; + _options = options; this.StatusCode = response.StatusCode; this.IsSuccessStatusCode = response.IsSuccessStatusCode; @@ -146,11 +146,11 @@ internal ContentstackResponse(HttpResponseMessage response, JsonSerializer seria /// /// Json Object format response. /// - /// The JObject. - public JObject OpenJObjectResponse() + /// The JsonObject. + public JsonObject OpenJsonObjectResponse() { ThrowIfDisposed(); - return JObject.Parse(OpenResponse()); + return JsonNode.Parse(OpenResponse())!.AsObject(); } /// @@ -171,8 +171,8 @@ public string OpenResponse() public TResponse OpenTResponse() { ThrowIfDisposed(); - JObject jObject = OpenJObjectResponse(); - return jObject.ToObject(_serializer); + JsonObject jObject = OpenJsonObjectResponse(); + return JsonSerializer.Deserialize(jObject.ToJsonString(), _options)!; } diff --git a/contentstack.model.generator/CMA/Http/IResponse.cs b/contentstack.model.generator/CMA/Http/IResponse.cs index 87b94d0..868b40f 100644 --- a/contentstack.model.generator/CMA/Http/IResponse.cs +++ b/contentstack.model.generator/CMA/Http/IResponse.cs @@ -1,6 +1,6 @@ using System; using System.Net; -using Newtonsoft.Json.Linq; +using System.Text.Json.Nodes; namespace contentstack.CMA.Http { @@ -19,7 +19,7 @@ public interface IResponse string OpenResponse(); - JObject OpenJObjectResponse(); + JsonObject OpenJsonObjectResponse(); TResponse OpenTResponse(); } diff --git a/contentstack.model.generator/CMA/OAuth/OAuthService.cs b/contentstack.model.generator/CMA/OAuth/OAuthService.cs index fa8c085..b3499ef 100644 --- a/contentstack.model.generator/CMA/OAuth/OAuthService.cs +++ b/contentstack.model.generator/CMA/OAuth/OAuthService.cs @@ -4,7 +4,8 @@ using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Serialization; namespace contentstack.CMA.OAuth { @@ -170,14 +171,15 @@ public static async Task ExchangeCodeForTokenAsync(Contentst // Parse response string responseContent = await response.Content.ReadAsStringAsync(); - var tokenResponse = JsonConvert.DeserializeObject(responseContent); - + var serializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var tokenResponse = JsonSerializer.Deserialize(responseContent, serializerOptions); + // Set expiration time if not already set if (tokenResponse.ExpiresAt == default(DateTime) && tokenResponse.ExpiresIn > 0) { tokenResponse.ExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn); } - + return tokenResponse; } } @@ -230,8 +232,9 @@ public static async Task RefreshTokenAsync(ContentstackOptio // Parse response string responseContent = await response.Content.ReadAsStringAsync(); - var tokenResponse = JsonConvert.DeserializeObject(responseContent); - + var serializerOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + var tokenResponse = JsonSerializer.Deserialize(responseContent, serializerOptions); + // Set expiration time if not already set if (tokenResponse.ExpiresAt == default(DateTime) && tokenResponse.ExpiresIn > 0) { @@ -408,25 +411,25 @@ internal static string GetDeveloperHubHostname(string baseHost) public class OAuthTokenResponse { - [JsonProperty("access_token")] + [JsonPropertyName("access_token")] public string AccessToken { get; set; } - [JsonProperty("refresh_token")] + [JsonPropertyName("refresh_token")] public string RefreshToken { get; set; } - [JsonProperty("token_type")] + [JsonPropertyName("token_type")] public string TokenType { get; set; } - [JsonProperty("expires_in")] + [JsonPropertyName("expires_in")] public int ExpiresIn { get; set; } - [JsonProperty("scope")] + [JsonPropertyName("scope")] public string Scope { get; set; } - [JsonProperty("organization_uid")] + [JsonPropertyName("organization_uid")] public string OrganizationUid { get; set; } - [JsonProperty("user_uid")] + [JsonPropertyName("user_uid")] public string UserUid { get; set; } // Computed property for expiration time diff --git a/contentstack.model.generator/Model/Contenttype.cs b/contentstack.model.generator/Model/Contenttype.cs index 0b6a593..fc91927 100644 --- a/contentstack.model.generator/Model/Contenttype.cs +++ b/contentstack.model.generator/Model/Contenttype.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace contentstack.model.generator.Model { @@ -11,7 +11,7 @@ public class Contenttype public List Schema { get; set; } - [JsonProperty(propertyName: "reference_to")] + [JsonPropertyName("reference_to")] public string ReferenceTo { get; set; } } } diff --git a/contentstack.model.generator/Model/Field.cs b/contentstack.model.generator/Model/Field.cs index c06ad9f..1d03abe 100644 --- a/contentstack.model.generator/Model/Field.cs +++ b/contentstack.model.generator/Model/Field.cs @@ -1,25 +1,25 @@ using System.Collections.Generic; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace contentstack.model.generator.Model { public class Field { - [JsonProperty(PropertyName = "display_name")] + [JsonPropertyName("display_name")] public string DisplayName { get; set; } public string Uid { get; set; } - [JsonProperty(PropertyName = "data_type")] + [JsonPropertyName("data_type")] public string DataType { get; set; } - [JsonProperty(PropertyName = "multiple")] + [JsonPropertyName("multiple")] public bool IsMultiple { get; set; } - [JsonProperty(PropertyName = "reference_to")] + [JsonPropertyName("reference_to")] public object ReferenceTo { get; set; } - [JsonProperty(PropertyName = "field_metadata")] + [JsonPropertyName("field_metadata")] public MetaData FieldMetadata { get; set; } public List Blocks { get; set; } diff --git a/contentstack.model.generator/Model/MetaData.cs b/contentstack.model.generator/Model/MetaData.cs index d1aa4b6..8ffed9d 100644 --- a/contentstack.model.generator/Model/MetaData.cs +++ b/contentstack.model.generator/Model/MetaData.cs @@ -1,28 +1,28 @@ -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace contentstack.model.generator.Model { public class MetaData { - [JsonProperty(PropertyName = "ref_multiple")] + [JsonPropertyName("ref_multiple")] public bool RefMultiple { get; set; } - [JsonProperty(PropertyName = "default_value")] + [JsonPropertyName("default_value")] public object DefaultValue { get; set; } - [JsonProperty(PropertyName = "ref_multiple_content_types")] + [JsonPropertyName("ref_multiple_content_types")] public bool RefMultipleContentType { get; set; } - [JsonProperty(PropertyName = "allow_rich_text")] + [JsonPropertyName("allow_rich_text")] public bool IsRichText { get; set; } - [JsonProperty(PropertyName = "markdown")] + [JsonPropertyName("markdown")] public bool IsMarkdown { get; set; } - [JsonProperty(PropertyName = "extension")] + [JsonPropertyName("extension")] public bool IsExtension { get; set; } - [JsonProperty(PropertyName = "allow_json_rte")] + [JsonPropertyName("allow_json_rte")] public bool IsJsonRTE { get; set; } } } diff --git a/contentstack.model.generator/Model/OAuth.cs b/contentstack.model.generator/Model/OAuth.cs new file mode 100644 index 0000000..a7a80b4 --- /dev/null +++ b/contentstack.model.generator/Model/OAuth.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; +using contentstack.model.generator.Model; + +namespace Contentstack.Model.Generator.Model +{ + /// + /// Represents the response from the OAuth app authorization API. + /// + public class OAuthAppAuthorizationResponse + { + + [JsonPropertyName("data")] + public OAuthAppAuthorizationData[] Data { get; set; } + } + + /// + /// Represents OAuth app authorization data. + /// + public class OAuthAppAuthorizationData + { + + [JsonPropertyName("authorization_uid")] + public string AuthorizationUid { get; set; } + + + [JsonPropertyName("user")] + public OAuthUser User { get; set; } + } + + + public class OAuthUser + { + + [JsonPropertyName("uid")] + public string Uid { get; set; } + } + + /// + /// Configuration options for OAuth authentication. + /// + public class OAuthOptions + { + /// + /// The OAuth application ID. Defaults to the Contentstack app ID. + /// + public string AppId { get; set; } = "6400aa06db64de001a31c8a9"; + + /// + /// The OAuth client ID. Defaults to the Contentstack client ID. + /// + public string ClientId { get; set; } = "Ie0FEfTzlfAHL4xM"; + + /// + /// The redirect URI for OAuth callbacks. Defaults to localhost:8184. + /// + public string RedirectUri { get; set; } = "http://localhost:8184"; + + /// + /// The OAuth client secret. If provided, PKCE flow will be skipped. + /// If null or empty, PKCE flow will be used for enhanced security. + /// + public string ClientSecret { get; set; } + + /// + /// The OAuth response type. Defaults to "code" for authorization code flow. + /// + public string ResponseType { get; set; } = "code"; + + /// + /// The OAuth scopes to request. Optional array of permission scopes. + /// + public string[] Scope { get; set; } + + /// + /// Indicates whether PKCE (Proof Key for Code Exchange) flow should be used. + /// This is automatically determined based on whether ClientSecret is provided. + /// + public bool UsePkce => string.IsNullOrEmpty(ClientSecret); + + /// + /// Validates the OAuth options configuration. + /// + /// True if the configuration is valid, false otherwise. + public bool IsValid() + { + return IsValid(out _); + } + + /// + /// Validates the OAuth options configuration and provides detailed error information. + /// + /// The validation error message if validation fails. + /// True if the configuration is valid, false otherwise. + public bool IsValid(out string errorMessage) + { + errorMessage = null; + + if (string.IsNullOrWhiteSpace(AppId)) + { + errorMessage = "AppId is required for OAuth configuration."; + return false; + } + + if (string.IsNullOrWhiteSpace(ClientId)) + { + errorMessage = "ClientId is required for OAuth configuration."; + return false; + } + + if (string.IsNullOrWhiteSpace(RedirectUri)) + { + errorMessage = "RedirectUri is required for OAuth configuration."; + return false; + } + + if (!Uri.TryCreate(RedirectUri, UriKind.Absolute, out var redirectUri)) + { + errorMessage = "RedirectUri must be a valid absolute URI."; + return false; + } + + if (redirectUri.Scheme != "http" && redirectUri.Scheme != "https") + { + errorMessage = "RedirectUri must use http or https scheme."; + return false; + } + + if (string.IsNullOrWhiteSpace(ResponseType)) + { + errorMessage = "ResponseType is required for OAuth configuration."; + return false; + } + + if (ResponseType != "code") + { + errorMessage = "ResponseType must be 'code' for authorization code flow."; + return false; + } + + // For traditional OAuth flow (non-PKCE), client secret is required + if (!UsePkce && string.IsNullOrWhiteSpace(ClientSecret)) + { + errorMessage = "ClientSecret is required for traditional OAuth flow. Use PKCE flow (leave ClientSecret empty) for public clients."; + return false; + } + + return true; + } + + /// + /// Validates the OAuth options configuration and throws an exception if invalid. + /// + /// Thrown when the configuration is invalid. + public void Validate() + { + if (!IsValid(out var errorMessage)) + { + throw new OAuthConfigurationException(errorMessage); + } + } + + /// + /// Gets a string representation of the OAuth options for debugging. + /// + /// A string representation of the OAuth options. + public override string ToString() + { + return $"OAuthOptions: AppId={AppId}, ClientId={ClientId}, RedirectUri={RedirectUri}, " + + $"ResponseType={ResponseType}, UsePkce={UsePkce}, HasScope={Scope?.Length > 0}"; + } + } + + /// + /// Represents the response from OAuth token exchange operations. + /// + public class OAuthResponse + { + + [JsonPropertyName("access_token")] + public string AccessToken { get; set; } + + + [JsonPropertyName("refresh_token")] + public string RefreshToken { get; set; } + + + [JsonPropertyName("expires_in")] + public int ExpiresIn { get; set; } + + + [JsonPropertyName("organization_uid")] + public string OrganizationUid { get; set; } + + + [JsonPropertyName("user_uid")] + public string UserUid { get; set; } + } + /// + /// Represents OAuth tokens stored in memory for cross-SDK access. + /// This class enables sharing OAuth tokens between the Management SDK and other SDKs + /// + public class OAuthTokens + { + + public string AccessToken { get; set; } + + public string RefreshToken { get; set; } + + public DateTime ExpiresAt { get; set; } + + public string OrganizationUid { get; set; } + + public string UserUid { get; set; } + + public string ClientId { get; set; } + + public string AppId { get; set; } + + public bool IsExpired => ExpiresAt == DateTime.MinValue || DateTime.UtcNow >= ExpiresAt; + + public bool NeedsRefresh + { + get + { + // If ExpiresAt is not set or is MinValue, consider it expired + if (ExpiresAt == DateTime.MinValue) + return true; + + try + { + // Check if we need to refresh (5 minutes before expiration) + var refreshTime = ExpiresAt.AddMinutes(-5); + return DateTime.UtcNow >= refreshTime || IsExpired; + } + catch (ArgumentOutOfRangeException) + { + // If the calculation results in an unrepresentable DateTime, consider it expired + return true; + } + } + } + + public bool IsValid => !string.IsNullOrEmpty(AccessToken) && !IsExpired; + } + + /// + /// Thrown when OAuth configuration validation fails in . + /// + public class OAuthConfigurationException : Exception + { + public OAuthConfigurationException(string message) : base(message) + { + } + } +} diff --git a/contentstack.model.generator/Model/StackResponse.cs b/contentstack.model.generator/Model/StackResponse.cs index 8d4c7b8..be0716a 100644 --- a/contentstack.model.generator/Model/StackResponse.cs +++ b/contentstack.model.generator/Model/StackResponse.cs @@ -1,14 +1,14 @@ using System; -using Newtonsoft.Json; +using System.Text.Json.Serialization; namespace Contentstack.Model.Generator.Model { public class StackResponse { - [JsonProperty(PropertyName = "api_key")] + [JsonPropertyName("api_key")] public string APIKey { get; set; } public string Name { get; set; } - [JsonProperty(PropertyName = "master_locale")] + [JsonPropertyName("master_locale")] public string MasterLocale { get; set; } public string Uid { get; set; } public string Description { get; set; } diff --git a/contentstack.model.generator/ModelGenerator.cs b/contentstack.model.generator/ModelGenerator.cs index 3550594..34a7035 100644 --- a/contentstack.model.generator/ModelGenerator.cs +++ b/contentstack.model.generator/ModelGenerator.cs @@ -12,8 +12,7 @@ using contentstack.model.generator.Model; using Contentstack.Model.Generator.Model; using McMaster.Extensions.CommandLineUtils; -using Newtonsoft.Json; -using Newtonsoft.Json.Linq; +using System.Text.Json; namespace contentstack.model.generator { @@ -66,7 +65,7 @@ public class ModelGenerator public string Path { get; } [Option(CommandOptionType.NoValue, ShortName = "N", LongName = "is-nullable", Description = "The features that protect against throwing a System.NullReferenceException can be disruptive when turned on.")] - public bool IsNullable { get; } + public bool IsNullable { get; internal set; } [VersionOption("0.5.1")] public bool Version { get; } @@ -79,12 +78,12 @@ public class ModelGenerator using Contentstack.Core.Models; using Contentstack.Utils.Models; using Contentstack.Utils.Interfaces; -using Newtonsoft.Json; -using Newtonsoft.Json.Converters; -using Newtonsoft.Json.Linq;"; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization;"; - private readonly List _contentTypes = new List(); - StackResponse stack; + internal readonly List _contentTypes = new List(); + internal StackResponse stack; public async Task OnExecute(CommandLineApplication app, IConsole console) { @@ -206,7 +205,7 @@ public async Task OnExecute(CommandLineApplication app, IConsole console) } Console.WriteLine(Messages.TotalGlobalFieldsFetched(totalCount)); } - catch (Exception e) + catch (Exception) { Console.ForegroundColor = ConsoleColor.Red; Console.Error.WriteLine(Messages.ApiCommunicationError); @@ -312,7 +311,7 @@ private string GetDatatype(Field field, string contentTypeName) return "object"; } - private string GetDatatypeForField(Field field, string contentTypeName) + internal string GetDatatypeForField(Field field, string contentTypeName) { string dataType = GetDatatype(field, contentTypeName); if (field.DataType == "reference" && DateTime.Compare(stack.Settings.version, DateTime.Parse("Apr, 04 2019")) >= 0) @@ -328,27 +327,29 @@ private string GetDatatypeForField(Field field, string contentTypeName) private string GetDatatypeForContentType(Field field) { - if (!field.FieldMetadata.RefMultipleContentType && !field.ReferenceTo.GetType().IsArray) - { - string referenceTo = (string)(field.ReferenceTo); - Contenttype contentType = _contentTypes.FirstOrDefault(c => c.Uid == referenceTo); - if (contentType != null) - { - return FormatClassName(contentType.Title); - } - } - else if (field.ReferenceTo.GetType() == typeof(JArray)) + if (field.ReferenceTo is JsonElement element) { - JArray array = field.ReferenceTo as JArray; - if (array.Count == 1) + if (!field.FieldMetadata.RefMultipleContentType && element.ValueKind == JsonValueKind.String) { - string referenceTo = (string)(array.First); + string referenceTo = element.GetString(); Contenttype contentType = _contentTypes.FirstOrDefault(c => c.Uid == referenceTo); if (contentType != null) { return FormatClassName(contentType.Title); } } + else if (element.ValueKind == JsonValueKind.Array) + { + if (element.GetArrayLength() == 1) + { + string referenceTo = element[0].GetString(); + Contenttype contentType = _contentTypes.FirstOrDefault(c => c.Uid == referenceTo); + if (contentType != null) + { + return FormatClassName(contentType.Title); + } + } + } } return "object"; } @@ -390,7 +391,7 @@ private string GetDataTypeForGroup(Field field, string contentTypeName) return $"{GroupPrefix}{contentTypeName}{FormatClassName(field.DisplayName)}".Replace(" ", ""); } - private void CreateEmbeddedObjectClass(string NameSpace, DirectoryInfo directoryInfo) + internal void CreateEmbeddedObjectClass(string NameSpace, DirectoryInfo directoryInfo) { string ConverterName = "IEmbeddedObjectConverter"; var file = shouldCreateFile(ConverterName, directoryInfo); @@ -401,84 +402,57 @@ private void CreateEmbeddedObjectClass(string NameSpace, DirectoryInfo directory var sb = new StringBuilder(); // Adding using at start of file var usingDirectives = @"using System; -using Newtonsoft.Json; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; using Contentstack.Core; -using Newtonsoft.Json.Linq; using Contentstack.Core.Models; using System.Collections.Generic; using Contentstack.Utils.Interfaces; "; sb.AppendLine(usingDirectives); - // Creating namespace + // Creating namespace var className = "List"; AddNameSpace($"{NameSpace}.{directoryInfo.Name}", sb); // Creating Enum sb.AppendLine($" [CSJsonConverter(\"{ConverterName}\")]"); AddClass($"{ConverterName} : JsonConverter<{className}>", sb); - sb.AppendLine($" protected {className} Create(Type objectType, JArray jArray)"); + sb.AppendLine($" public override {className} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)"); sb.AppendLine(" {"); - sb.AppendLine($" return new List();"); - sb.AppendLine(" }"); - - sb.AppendLine($" public override {className}{nullableString()} ReadJson(JsonReader reader, Type objectType, {className}{nullableString()} existingValue, bool hasExistingValue, JsonSerializer serializer)"); - sb.AppendLine(" {"); - sb.AppendLine(" JArray jArray = JArray.Load(reader);"); - sb.AppendLine($" {className} target = Create(objectType, jArray);"); - sb.AppendLine(" foreach (JObject obj in jArray)"); + sb.AppendLine(" using var doc = JsonDocument.ParseValue(ref reader);"); + sb.AppendLine(" var jArray = JsonNode.Parse(doc.RootElement.GetRawText())!.AsArray();"); + sb.AppendLine(" var target = new List();"); + sb.AppendLine(" var deserializeOptions = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };"); + sb.AppendLine(" foreach (var node in jArray)"); sb.AppendLine(" {"); + sb.AppendLine(" if (node is not JsonObject obj) continue;"); + sb.AppendLine(" var ctUid = obj[\"_content_type_uid\"]?.GetValue();"); var includeElse = false; foreach (var contentType in _contentTypes) { - sb.AppendLine($" {(includeElse ? "else " : "")}if ((string{nullableString()})obj.GetValue(\"_content_type_uid\") == \"{contentType.Uid}\")"); + sb.AppendLine($" {(includeElse ? "else " : "")}if (ctUid == \"{contentType.Uid}\")"); sb.AppendLine(" {"); - sb.AppendLine($" {FormatClassName(contentType.Title)}{nullableString()} {FirstLetterToUpperCase(contentType.Uid)} = obj.ToObject<{FormatClassName(contentType.Title)}>();"); - if (IsNullable) - { - sb.AppendLine($" if ({FirstLetterToUpperCase(contentType.Uid)} != null) {{"); - } - sb.AppendLine($" target.Add({FirstLetterToUpperCase(contentType.Uid)});"); - if (IsNullable) - { - sb.AppendLine($" }}"); - } + sb.AppendLine($" var {FirstLetterToUpperCase(contentType.Uid)} = JsonSerializer.Deserialize<{FormatClassName(contentType.Title)}>(obj.ToJsonString(), deserializeOptions);"); + sb.AppendLine($" if ({FirstLetterToUpperCase(contentType.Uid)} != null) target.Add({FirstLetterToUpperCase(contentType.Uid)});"); sb.AppendLine(" }"); includeElse = true; } // Embedded Asset Object - sb.AppendLine($" {(includeElse ? "else " : "")}if ((string{nullableString()})obj.GetValue(\"_content_type_uid\") == \"sys_assets\")"); + sb.AppendLine($" {(includeElse ? "else " : "")}if (ctUid == \"sys_assets\")"); sb.AppendLine(" {"); - sb.AppendLine($" Asset{nullableString()} asset = obj.ToObject();"); - if (IsNullable) - { - sb.AppendLine($" if (asset != null) {{"); - } - sb.AppendLine($" target.Add(asset);"); - if (IsNullable) - { - sb.AppendLine($" }}"); - } + sb.AppendLine(" var asset = JsonSerializer.Deserialize(obj.ToJsonString(), deserializeOptions);"); + sb.AppendLine(" if (asset != null) target.Add(asset);"); sb.AppendLine(" }"); sb.AppendLine(" }"); sb.AppendLine(" return target;"); sb.AppendLine(" }"); - sb.AppendLine($" public override void WriteJson(JsonWriter writer, {className}{nullableString()} value, JsonSerializer serializer)"); - sb.AppendLine(" {"); - if (IsNullable) - { - sb.AppendLine($" if (value != null) {{"); - } - sb.AppendLine(" JToken t = JToken.FromObject(value);"); - sb.AppendLine(" t.WriteTo(writer);"); - if (IsNullable) - { - sb.AppendLine($" }}"); - } - sb.AppendLine(" }"); + sb.AppendLine($" public override void Write(Utf8JsonWriter writer, {className} value, JsonSerializerOptions options)"); + sb.AppendLine(" => JsonSerializer.Serialize(writer, value, options);"); // End of namespace and Enum AddEnd(sb); @@ -490,7 +464,7 @@ private void CreateEmbeddedObjectClass(string NameSpace, DirectoryInfo directory } } - private void CreateDisplayAttributeClass(string NameSpace, DirectoryInfo directoryInfo) + internal void CreateDisplayAttributeClass(string NameSpace, DirectoryInfo directoryInfo) { // Create File for DisplayAttribute string contentstackLinkClass = "DisplayNameAttribute"; @@ -532,7 +506,7 @@ private void CreateDisplayAttributeClass(string NameSpace, DirectoryInfo directo } } } - private void CreateLinkClass(string NameSpace, DirectoryInfo directoryInfo) + internal void CreateLinkClass(string NameSpace, DirectoryInfo directoryInfo) { // Create File for LinkClass string contentstackLinkClass = "ContentstackLink"; @@ -550,9 +524,9 @@ private void CreateLinkClass(string NameSpace, DirectoryInfo directoryInfo) // Creating Class AddClass(contentstackLinkClass, sb); - sb.AppendLine($" [JsonProperty(propertyName: \"title\")]"); + sb.AppendLine($" [JsonPropertyName(\"title\")]"); sb.AppendLine($" public string{nullableString()} Title {{ get; set; }}"); - sb.AppendLine($" [JsonProperty(propertyName: \"href\")]"); + sb.AppendLine($" [JsonPropertyName(\"href\")]"); sb.AppendLine($" public string{nullableString()} Href {{ get; set; }}"); // End of namespace and class @@ -668,7 +642,7 @@ private void CreateModularFile(string contentTypeName, string nameSpace, Content } } - private Boolean findRTEReference(List Schema) + internal Boolean findRTEReference(List Schema) { if (Schema == null) { @@ -696,7 +670,7 @@ private Boolean findRTEReference(List Schema) return fields.Count > 0; } - private void CreateFile(string contentTypeName, string nameSpace, Contenttype contentType, DirectoryInfo directoryInfo) + internal void CreateFile(string contentTypeName, string nameSpace, Contenttype contentType, DirectoryInfo directoryInfo) { Console.WriteLine(Messages.ExtractingModularBlocksInContentType(contentTypeName)); @@ -741,9 +715,9 @@ private void CreateFile(string contentTypeName, string nameSpace, Contenttype co // Add Const sb.AppendLine($" public const string ContentType = \"{contentType.Uid}\";"); - sb.AppendLine($" [JsonProperty(propertyName: \"uid\")]"); + sb.AppendLine($" [JsonPropertyName(\"uid\")]"); sb.AppendLine($" public string{nullableString()} Uid {{ get; set; }}"); - sb.AppendLine($" [JsonProperty(propertyName: \"_content_type_uid\")]"); + sb.AppendLine($" [JsonPropertyName(\"_content_type_uid\")]"); sb.AppendLine($" public string{nullableString()} ContentTypeUid {{ get; set; }}"); //Adding Params to contentType @@ -751,8 +725,8 @@ private void CreateFile(string contentTypeName, string nameSpace, Contenttype co if (fields) { - sb.AppendLine($" [JsonProperty(propertyName: \"_embedded_items\")]"); - sb.AppendLine(" public Dictionary>{nullableString()} embeddedItems { get; set; }"); + sb.AppendLine($" [JsonPropertyName(\"_embedded_items\")]"); + sb.AppendLine($" public Dictionary>{nullableString()} embeddedItems {{ get; set; }}"); } // End of namespace and class @@ -871,11 +845,11 @@ private Tuple> ExtractFieldType(strin return new Tuple>(usingDirective, NameSpace, directory, fields); } - private void AddParams(string contentType, List schema, in StringBuilder sb) + internal void AddParams(string contentType, List schema, in StringBuilder sb) { foreach (var field in schema) { - sb.AppendLine($" [JsonProperty(propertyName: \"{field.Uid}\")]"); + sb.AppendLine($" [JsonPropertyName(\"{field.Uid}\")]"); if (field.DataType == "text" && field.FieldMetadata.IsMarkdown) { sb.AppendLine($" public {GetDatatypeForField(field, contentType)} {FirstLetterToUpperCase(field.Uid)} {{"); @@ -1051,7 +1025,7 @@ private void CreateModularBlockEnum(string enumName, string nameSpace, Dictionar } } - private void CreateHelperClass(string nameSpace, DirectoryInfo directoryInfo) + internal void CreateHelperClass(string nameSpace, DirectoryInfo directoryInfo) { string className = "ContentstackHelper"; FileInfo file = shouldCreateFile(className, directoryInfo); @@ -1084,7 +1058,7 @@ private void CreateHelperClass(string nameSpace, DirectoryInfo directoryInfo) sb.AppendLine(" }"); - sb.AppendLine(" public static bool FieldExists(string fieldName, JObject jObject)"); + sb.AppendLine(" public static bool FieldExists(string fieldName, JsonObject jObject)"); sb.AppendLine(" {"); sb.AppendLine(" return jObject[fieldName] != null;"); sb.AppendLine(" }"); @@ -1097,7 +1071,7 @@ private void CreateHelperClass(string nameSpace, DirectoryInfo directoryInfo) } } - private void CreateStringHelperClass(string nameSpace, DirectoryInfo directoryInfo) + internal void CreateStringHelperClass(string nameSpace, DirectoryInfo directoryInfo) { string className = "ContentstackStringExtension"; FileInfo file = shouldCreateFile(className, directoryInfo); @@ -1147,7 +1121,7 @@ private void CreateStringHelperClass(string nameSpace, DirectoryInfo directoryIn } } - private void CreateModularBlockConverter(string nameSpace, string className, Dictionary blockTypes, DirectoryInfo directoryInfo) + internal void CreateModularBlockConverter(string nameSpace, string className, Dictionary blockTypes, DirectoryInfo directoryInfo) { FileInfo file = shouldCreateFile($"{className}Converter", directoryInfo); @@ -1158,19 +1132,18 @@ private void CreateModularBlockConverter(string nameSpace, string className, Dic var sb = new StringBuilder(); sb.AppendLine("using System;"); - sb.AppendLine("using Newtonsoft.Json;"); - sb.AppendLine("using System.Reflection;"); - sb.AppendLine("using Newtonsoft.Json.Linq;"); - sb.AppendLine("using System.ComponentModel;"); + sb.AppendLine("using System.Text.Json;"); + sb.AppendLine("using System.Text.Json.Nodes;"); + sb.AppendLine("using System.Text.Json.Serialization;"); sb.AppendLine("using Contentstack.Core;"); - // Creating namespace + // Creating namespace AddNameSpace($"{nameSpace}.{directoryInfo.Name}", sb); // Creating Enum var ConverterName = $"{className}Converter"; sb.AppendLine($" [CSJsonConverter(\"{ConverterName}\")]"); AddClass($"{ConverterName} : JsonConverter<{className}>", sb); - sb.AppendLine($" protected {className} Create(Type objectType, JObject jObject)"); + sb.AppendLine($" protected {className} Create(Type objectType, JsonObject jObject)"); sb.AppendLine(" {"); foreach (var blocks in blockTypes) { @@ -1186,35 +1159,30 @@ private void CreateModularBlockConverter(string nameSpace, string className, Dic sb.AppendLine($" return new {className}();"); sb.AppendLine(" }"); - sb.AppendLine($" public override {className}{nullableString()} ReadJson(JsonReader reader, Type objectType, {className}{nullableString()} existingValue, bool hasExistingValue, JsonSerializer serializer)"); + sb.AppendLine($" public override {className}{nullableString()} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)"); sb.AppendLine(" {"); - sb.AppendLine(" JObject jObject = JObject.Load(reader);"); - sb.AppendLine($" {className} target = Create(objectType, jObject);"); - sb.AppendLine(" var token = jObject.GetValue(ContentstackHelper.GetDescription(target.BlockType));"); - if (IsNullable) - { - sb.AppendLine($" if (token != null) {{"); - } - sb.AppendLine(" serializer.Populate(token.CreateReader(), target);"); - if (IsNullable) - { - sb.AppendLine($" }}"); - } + sb.AppendLine(" using var doc = JsonDocument.ParseValue(ref reader);"); + sb.AppendLine(" var jObject = JsonNode.Parse(doc.RootElement.GetRawText())!.AsObject();"); + sb.AppendLine($" {className} target = Create(typeToConvert, jObject);"); + sb.AppendLine(" var token = jObject[ContentstackHelper.GetDescription(target.BlockType)];"); + sb.AppendLine(" if (token != null)"); + sb.AppendLine(" {"); + sb.AppendLine($" var filled = JsonSerializer.Deserialize(token.ToJsonString(), target.GetType(), new JsonSerializerOptions {{ PropertyNameCaseInsensitive = true }}) as {className};"); + sb.AppendLine(" if (filled != null)"); + sb.AppendLine(" {"); + sb.AppendLine(" filled.BlockType = target.BlockType;"); + sb.AppendLine(" return filled;"); + sb.AppendLine(" }"); + sb.AppendLine(" }"); sb.AppendLine(" return target;"); sb.AppendLine(" }"); - sb.AppendLine($" public override void WriteJson(JsonWriter writer, {className}{nullableString()} value, JsonSerializer serializer)"); + sb.AppendLine($" public override void Write(Utf8JsonWriter writer, {className}{nullableString()} value, JsonSerializerOptions options)"); sb.AppendLine(" {"); - if (IsNullable) - { - sb.AppendLine($" if (value != null) {{"); - } - sb.AppendLine(" JToken t = JToken.FromObject(value);"); - sb.AppendLine(" t.WriteTo(writer);"); - if (IsNullable) - { - sb.AppendLine($" }}"); - } + sb.AppendLine(" if (value != null)"); + sb.AppendLine(" {"); + sb.AppendLine(" JsonSerializer.Serialize(writer, value, options);"); + sb.AppendLine(" }"); sb.AppendLine(" }"); // End of namespace and Enum diff --git a/contentstack.model.generator/Program.cs b/contentstack.model.generator/Program.cs index 0070bb5..f54132c 100644 --- a/contentstack.model.generator/Program.cs +++ b/contentstack.model.generator/Program.cs @@ -14,7 +14,7 @@ protected Program() { } - static async Task Main(string[] args) + internal static async Task Main(string[] args) { try { diff --git a/contentstack.model.generator/contentstack.model.generator.csproj b/contentstack.model.generator/contentstack.model.generator.csproj index 7112a5c..1347695 100644 --- a/contentstack.model.generator/contentstack.model.generator.csproj +++ b/contentstack.model.generator/contentstack.model.generator.csproj @@ -2,7 +2,7 @@ Exe - net7.0 + net10.0 contentstack.model.generator Copyright © 2012-2026 Contentstack. All Rights Reserved en-US @@ -13,15 +13,15 @@ true ./nupkg true - 0.5.1 + 1.0.0 Contentstack - 0.5.1 + 1.0.0 Contentstack.Model.Generator LICENSE.txt - Improved Error messages + See CHANGELOG.md for full release notes. true true - v0.5.1 + contentstack;model-generator;codegen;cli;dotnet-tool;csharp;net10;system-text-json;cms;headless-cms Release;Debug @@ -31,7 +31,6 @@ - diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 0000000..614871f --- /dev/null +++ b/skills/README.md @@ -0,0 +1,12 @@ +# Skills – Contentstack Model Generator + +**Entry point:** [AGENTS.md](../AGENTS.md) — commands, stack, CI, and **which skill to open**. + +Each folder holds **SKILL.md** (YAML frontmatter: `name`, `description`). + +- [dev-workflow/SKILL.md](dev-workflow/SKILL.md) +- [testing/SKILL.md](testing/SKILL.md) +- [code-review/SKILL.md](code-review/SKILL.md) +- [csharp-dotnet/SKILL.md](csharp-dotnet/SKILL.md) +- [framework/SKILL.md](framework/SKILL.md) +- [contentstack-model-generator/SKILL.md](contentstack-model-generator/SKILL.md) diff --git a/skills/code-review/SKILL.md b/skills/code-review/SKILL.md new file mode 100644 index 0000000..261aedd --- /dev/null +++ b/skills/code-review/SKILL.md @@ -0,0 +1,40 @@ +--- +name: code-review +description: PR checklist, branch policy, versioning notes, and security scan context for this repository. +--- + +# Code review – Contentstack Model Generator + +## When to use + +- Opening or reviewing a pull request. +- Checking release readiness (version, changelog, package metadata). +- Ensuring changes align with automated security and branch rules. + +## Instructions + +### Branch policy + +- PRs **into `master`** must be **from `staging`** per [.github/workflows/check-branch.yml](../../.github/workflows/check-branch.yml). Verify head/base before approving merges to `master`. +- If the PR does not target `master`, the same rule may not apply; still follow team conventions for default branch. + +### Review checklist + +- **Build and tests:** `dotnet build` and `dotnet test` on `contentstack.model.generator/contentstack.model.generator.sln` succeed (CI does not run `dotnet test`; see [dev-workflow/SKILL.md](../dev-workflow/SKILL.md)). +- **Release versioning:** For releases, keep `[VersionOption]` in [ModelGenerator.cs](../../contentstack.model.generator/ModelGenerator.cs) (CLI `--version`), **`PackageVersion` / `ReleaseVersion`** in [contentstack.model.generator.csproj](../../contentstack.model.generator/contentstack.model.generator.csproj), and **[CHANGELOG.md](../../CHANGELOG.md)** aligned. +- **Auth paths:** Traditional `authtoken` vs `--oauth` remain mutually consistent; no accidental logging of secrets. +- **Codegen output:** Changes to `ModelGenerator` or templates do not break emitted class shapes without **[CHANGELOG.md](../../CHANGELOG.md)** and version bump in **[contentstack.model.generator.csproj](../../contentstack.model.generator/contentstack.model.generator.csproj)** when releasing. +- **Dependencies:** New package references are justified; Snyk/SCA will scan on PRs ([sca-scan.yml](../../.github/workflows/sca-scan.yml)). +- **Security-sensitive code:** HTTP, OAuth, and error handling reviewed for information disclosure and robust failure modes. + +### Automated security analysis + +- **Snyk (SCA):** [.github/workflows/sca-scan.yml](../../.github/workflows/sca-scan.yml) — dependency vulnerabilities. +- **CodeQL:** [.github/workflows/codeql-analysis.yml](../../.github/workflows/codeql-analysis.yml) — static analysis for C#. + +Treat new findings from these workflows as blockers until triaged or waived with documented rationale. + +### Product and licensing + +- **License** and **copyright** in packaged output follow `contentstack.model.generator.csproj` and [LICENSE](../../LICENSE) / packaged `LICENSE.txt` as applicable. +- **Security:** Report vulnerabilities per [SECURITY.md](../../SECURITY.md) (not via public GitHub issues). \ No newline at end of file diff --git a/skills/contentstack-model-generator/SKILL.md b/skills/contentstack-model-generator/SKILL.md new file mode 100644 index 0000000..e1c82a6 --- /dev/null +++ b/skills/contentstack-model-generator/SKILL.md @@ -0,0 +1,69 @@ +--- +name: contentstack-model-generator +description: CLI entry, CMA and OAuth flow, HTTP layer, and C# code generation pipeline for the Contentstack Model Generator tool. +--- + +# Contentstack Model Generator – product and runtime flow + +## When to use + +- Changing **CLI flags**, validation, or exit codes. +- Modifying **authentication** (authtoken vs OAuth / PKCE) or **CMA** calls. +- Adjusting **HTTP** behavior or headers. +- Changing **generated C#** (models, helpers, converters, modular blocks, groups). + +## Runtime flow (high level) + +Entry: [Program.cs](../../contentstack.model.generator/Program.cs) calls `CommandLineApplication.ExecuteAsync`. Uncaught exceptions print `Messages.UnexpectedError` and return exit code `EXCEPTION` (`2`). + +Orchestration: [ModelGenerator.cs](../../contentstack.model.generator/ModelGenerator.cs) — `[Command]` class `ModelGenerator`, method **`OnExecute`**. + +### Phases in `ModelGenerator.OnExecute` (order matters) + +1. **Validate auth** — If `--oauth`: require client id and redirect URI (and optional secret). Else: require `--authtoken` (`-A`). +2. **Build `ContentstackOptions`** — API key, host, branch, OAuth fields, optional scopes list. +3. **OAuth only** — `HandleOAuthFlow`: PKCE verifier → authorization URL → user pastes authorization code → `OAuthService.ExchangeCodeForTokenAsync` → set `Authorization` Bearer (and tokens) on `options`. +4. **`ContentstackClient`** — Construct with `ContentstackOptions`; constructor sets headers (`api_key`, `authtoken` **or** `Authorization`, optional `branch`) and `Config` from host/version. +5. **`GetStack()`** — Validates connectivity and loads stack metadata (`StackResponse`). +6. **Output path** — Resolve `-p` / cwd; append **`/Models`**; create directory if needed. +7. **Paginated fetch** — `getContentTypes` and `getGlobalFields` with `skip` in steps of **100** until all rows fetched; both append into **`_contentTypes`**. +8. **Shared generated files** — `CreateEmbeddedObjectClass`, `CreateLinkClass`, `CreateHelperClass`, `CreateStringHelperClass`, `CreateDisplayAttributeClass` (helpers/converters used by emitted types). +9. **Per content type** — `CreateFile` for each `Contenttype` in `_contentTypes` (handles modular blocks, groups, RTE references via nested `Create*` methods). +10. **Finish** — Success messages; `OpenFolderatPath` opens the `Models` folder in the OS shell. +11. **OAuth cleanup** — `OAuthService.LogoutAsync` (failures logged as warnings, non-fatal). + +Exit codes: `Program.OK` (`0`), `Program.ERROR` (`1`) on user/API/OAuth failures; `Program.EXCEPTION` (`2`) only from `Program.Main` on unexpected exceptions. + +## Layer map + +| Layer | Responsibility | Main paths | +| --- | --- | --- | +| **CLI bootstrap** | Parse args, top-level exception handling | [Program.cs](../../contentstack.model.generator/Program.cs) | +| **Command + codegen** | Options, auth, fetch, emit C# text | [ModelGenerator.cs](../../contentstack.model.generator/ModelGenerator.cs) | +| **CMA client** | Stack, content types, global fields URLs; header assembly; JSON deserialize | [ContentstackClient.cs](../../contentstack.model.generator/CMA/ContentstackClient.cs) | +| **HTTP** | GET requests, query string, `Authorization` vs custom headers | [HTTPRequestHandler.cs](../../contentstack.model.generator/CMA/HTTPRequestHandler.cs) | +| **OAuth** | PKCE, authorize URL, token exchange, logout | [OAuthService.cs](../../contentstack.model.generator/CMA/OAuth/OAuthService.cs), [ContentstackOptions.cs](../../contentstack.model.generator/CMA/ContentstackOptions.cs) | +| **DTOs** | Content types, fields, stack/API JSON shapes; OAuth token/app JSON | [Model/](../../contentstack.model.generator/Model/) — including [OAuth.cs](../../contentstack.model.generator/Model/OAuth.cs) (`Contentstack.Model.Generator.Model`) | + +### Debugging API errors + +`ContentstackClient.GetContentstackException` is written around **`WebException`**. [HTTPRequestHandler](../../contentstack.model.generator/CMA/HTTPRequestHandler.cs) uses **`HttpClient`** and may throw **`HttpRequestException`**. Parsed CMA error JSON from `GetContentstackException` may not apply on every failure path — check the **actual exception type** when debugging HTTP failures. + +## Extension points + +| Goal | Where to change | +| --- | --- | +| New CLI flag | `[Option]` properties on `ModelGenerator`; consume in `OnExecute`; document in [README.md](../../README.md). | +| User-facing CLI strings | [Messages.cs](../../contentstack.model.generator/Messages.cs) | +| New header or API call | `ContentstackClient` methods and/or `HTTPRequestHandler.ProcessRequest`; keep header rules consistent with OAuth vs authtoken. | +| Field type → C# mapping | `GetDatatype`, `GetDatatypeForField`, `GetDatatypeForContentType` in `ModelGenerator`. | +| New global emitted type | Add a `Create*` method and invoke it in the same phase as related helpers (order matters for dependencies). | +| Modular blocks / groups | `CreateModularBlocks`, `CreateGroup`, `CreateModularBlockConverter`, `CreateModularBlockEnum`, etc. | + +### File overwrite behavior + +`shouldCreateFile` prompts when a file exists unless `--force` (`-f`) is set; returns `null` to skip writing. + +User-facing OAuth and install/flags: [OAuth.md](../../OAuth.md), [README.md](../../README.md) (consumer apps need NuGets for generated code: **Contentstack.Core**, **Contentstack.Utils**, **Newtonsoft.Json**, **Markdig** where markdown helpers are used). + +If this document grows unwieldy, consider splitting **only** the emit pipeline (modular blocks, groups, `GetDatatype*`) into a separate `code-generation-pipeline` skill and link it from here. diff --git a/skills/csharp-dotnet/SKILL.md b/skills/csharp-dotnet/SKILL.md new file mode 100644 index 0000000..044b2de --- /dev/null +++ b/skills/csharp-dotnet/SKILL.md @@ -0,0 +1,35 @@ +--- +name: csharp-dotnet +description: .NET TFMs, namespaces, and source layout conventions for Contentstack Model Generator. +--- + +# C# / .NET – Contentstack Model Generator + +## When to use + +- Adding new `.cs` files or projects. +- Resolving confusion between tool and test target frameworks. +- Matching existing namespace and folder patterns. + +## Instructions + +### Target frameworks + +| Project | TFM | Notes | +| --- | --- | --- | +| `contentstack.model.generator` | `net7.0` | Global tool executable; `PackAsTool` in csproj. | +| `contentstack.model.generator.tests` | `net9.0` | `ImplicitUsings` and `Nullable` enabled in test csproj only. | + +When adding code to the **tool**, assume **.NET 7** language/API surface unless you raise the TFM in the csproj. + +### Namespaces and folders + +- **CLI entry and codegen:** `contentstack.model.generator` — e.g. [Program.cs](../../contentstack.model.generator/Program.cs), [ModelGenerator.cs](../../contentstack.model.generator/ModelGenerator.cs). Root namespace in csproj is `Contentstack.Model.Generator`; some types use `contentstack.model.generator` / `contentstack.CMA` style — **follow the file you are editing**. +- **CMA / HTTP / OAuth:** [contentstack.model.generator/CMA/](../../contentstack.model.generator/CMA/) — `ContentstackClient`, `HTTPRequestHandler`, `ContentstackOptions`, `OAuth/OAuthService`. +- **JSON DTOs / models:** [contentstack.model.generator/Model/](../../contentstack.model.generator/Model/) — e.g. `Contenttype`, `Field`, stack responses. + +### Conventions for new code + +- Prefer **async** patterns consistent with existing CMA methods (`async Task`, `await`). +- Use **Newtonsoft.Json** attributes and patterns already used in generated and hand-written models unless migrating the whole project. +- Avoid **public API** expansion on the tool’s CLI without updating [README.md](../../README.md) and the `[VersionOption]` / package version in csproj when releasing. diff --git a/skills/dev-workflow/SKILL.md b/skills/dev-workflow/SKILL.md new file mode 100644 index 0000000..aac5903 --- /dev/null +++ b/skills/dev-workflow/SKILL.md @@ -0,0 +1,56 @@ +--- +name: dev-workflow +description: Branches, CI, and dotnet restore/build/test/pack for the Contentstack Model Generator solution. +--- + +# Dev workflow – Contentstack Model Generator + +## When to use + +- Setting up a local build or verifying changes before a PR. +- Understanding which GitHub Actions run on PRs or releases. +- Packing or publishing the global tool NuGet package. +- Anything about **how the CLI and codegen behave** at runtime — see [contentstack-model-generator/SKILL.md](../contentstack-model-generator/SKILL.md). + +## Instructions + +### Repository layout + +- **Solution:** `contentstack.model.generator/contentstack.model.generator.sln` +- **Tool (executable + pack):** `contentstack.model.generator/contentstack.model.generator.csproj` (`PackAsTool`, `net7.0`). +- **Tests:** `contentstack.model.generator.tests/contentstack.model.generator.tests.csproj` (`net9.0`). + +### Commands (from repository root) + +| Step | Command | +| --- | --- | +| Restore | `dotnet restore contentstack.model.generator/contentstack.model.generator.sln` | +| Build | `dotnet build contentstack.model.generator/contentstack.model.generator.sln` | +| Test | `dotnet test contentstack.model.generator/contentstack.model.generator.sln` | +| Pack Release | `cd contentstack.model.generator && dotnet pack -c Release -o out` (output `.nupkg` under `contentstack.model.generator/out/`; CI uses similar `dotnet pack` from that directory). | + +Package version and metadata: see `PackageVersion` / `ReleaseVersion` in `contentstack.model.generator/contentstack.model.generator.csproj` (avoid duplicating version numbers in prose here). + +**CI and unit tests:** No workflow under [.github/workflows/](../../.github/workflows/) runs `dotnet test`. Run `dotnet test contentstack.model.generator/contentstack.model.generator.sln` **locally** before relying on a PR. + +**Talisman:** [.talismanrc](../../.talismanrc) may configure secret scanning for this repo. If it references workflow paths that no longer exist (e.g. `.github/workflows/secrets-scan.yml`), update ignores or restore the workflow in a follow-up change. + +### Branches and PRs + +- Workflow [.github/workflows/check-branch.yml](../../.github/workflows/check-branch.yml): pull requests **into `master`** must come **from `staging`** (other combinations get a failing check and PR comment). +- Plan feature work accordingly: integrate to `staging` first unless your team documents an exception. + +### CI and security workflows (overview) + +| Workflow | Role | +| --- | --- | +| [check-branch.yml](../../.github/workflows/check-branch.yml) | Enforces `staging` → `master` for PRs targeting `master`. | +| [sca-scan.yml](../../.github/workflows/sca-scan.yml) | `dotnet restore` + Snyk on .NET assets. | +| [codeql-analysis.yml](../../.github/workflows/codeql-analysis.yml) | CodeQL for C#. | +| [nuget-publish.yml](../../.github/workflows/nuget-publish.yml) | On **release created**: pack and push NuGet package(s). | +| [policy-scan.yml](../../.github/workflows/policy-scan.yml), [issues-jira.yml](../../.github/workflows/issues-jira.yml) | Org/process automation as configured. | + +### PR expectations (summary) + +- Run **build + test** locally before opening or updating a PR. +- For user-visible behavior or releases, coordinate **version** and **[CHANGELOG.md](../../CHANGELOG.md)** with maintainers (see [code-review/SKILL.md](../code-review/SKILL.md)). diff --git a/skills/framework/SKILL.md b/skills/framework/SKILL.md new file mode 100644 index 0000000..fac34fb --- /dev/null +++ b/skills/framework/SKILL.md @@ -0,0 +1,34 @@ +--- +name: framework +description: Third-party and platform libraries used by the tool—CLI, JSON, HTTP, and CMA base URL config—not business logic in ModelGenerator. +--- + +# Framework – Contentstack Model Generator + +## When to use + +- Changing **command-line parsing** (McMaster), **JSON** settings, or **HTTP** behavior. +- Understanding how **CMA base URL** is built from host/protocol (`Config` / `ContentstackOptions`). +- Adding or upgrading **NuGet dependencies** on the tool project (with SCA/CodeQL in mind). +- **Not** for end-to-end product flow — see [contentstack-model-generator/SKILL.md](../contentstack-model-generator/SKILL.md). + +## What this repo uses (tool project) + +Declared in [contentstack.model.generator.csproj](../../contentstack.model.generator/contentstack.model.generator.csproj): + +| Area | Library / API | Where it shows up | +| --- | --- | --- | +| **CLI** | **McMaster.Extensions.CommandLineUtils** | [Program.cs](../../contentstack.model.generator/Program.cs) `CommandLineApplication.ExecuteAsync`; `[Command]`, `[Option]`, `[VersionOption]` on [ModelGenerator.cs](../../contentstack.model.generator/ModelGenerator.cs). | +| **JSON** | **Newtonsoft.Json** | Deserialization in [ContentstackClient.cs](../../contentstack.model.generator/CMA/ContentstackClient.cs) (`JsonConvert`, `JObject`); generated code templates in `ModelGenerator` also emit Newtonsoft attributes. | +| **HTTP** | **`System.Net.Http.HttpClient`** (via [HTTPRequestHandler.cs](../../contentstack.model.generator/CMA/HTTPRequestHandler.cs)) | GET requests with query string; maps `Authorization` / `api_key` / `authtoken` headers; sets `x-user-agent`. No Polly/retry stack in-repo—retries are not a built-in feature here. | +| **CMA URL** | [Config.cs](../../contentstack.model.generator/CMA/Config.cs) | Internal `Config` holds protocol, host, port, API version; combined into `BaseUrl` for stack/content_types/global_fields paths used by `ContentstackClient`. | + +## Conventions + +- **CLI:** Prefer existing McMaster patterns (`[Option]`, `CommandOptionType`, validation attributes) on `ModelGenerator`; keep `--help` and README in sync when adding flags. +- **JSON:** Respect `JsonSerializerSettings` on `ContentstackClient` when changing deserialize behavior; generated models assume Newtonsoft on the **consumer** side too (see [AGENTS.md](../../AGENTS.md) consumer packages). +- **HTTP:** Changes to TLS, timeouts, or default `HttpClient` lifetime should stay centralized in `HTTPRequestHandler` (or the type that owns the client) to avoid duplicated policy. + +## Packaging + +The tool is shipped as a **.NET global tool** (`PackAsTool`). Commands and CI paths: [dev-workflow/SKILL.md](../dev-workflow/SKILL.md). diff --git a/skills/testing/SKILL.md b/skills/testing/SKILL.md new file mode 100644 index 0000000..d5f16de --- /dev/null +++ b/skills/testing/SKILL.md @@ -0,0 +1,146 @@ +--- +name: testing +description: xUnit test layout, how to add new tests, tooling, test file map, and credentials policy for Contentstack Model Generator. +--- + +# Testing – Contentstack Model Generator + +## When to use + +- Adding or fixing unit/integration tests for OAuth, HTTP, CMA client, options, or codegen. +- Running tests locally with coverage expectations. +- Deciding how to handle API keys, tokens, or secrets in tests. + +## Instructions + +### Test project + +- **Path:** `contentstack.model.generator.tests/contentstack.model.generator.tests.csproj` +- **Framework:** `net9.0` +- **Packages:** xUnit, Moq, `Microsoft.NET.Test.Sdk`, `coverlet.collector`, `Microsoft.AspNetCore.Mvc.Testing` (see csproj for versions). +- **`Microsoft.AspNetCore.Mvc.Testing`:** Referenced in the test csproj but **not used** by any current `.cs` test file (no `WebApplicationFactory` / similar). Safe candidate for removal in a dedicated change, or keep if you plan integration-style web tests. +- **Internals:** Main project exposes internals to the test assembly via `InternalsVisibleTo` in `contentstack.model.generator/contentstack.model.generator.csproj`. + +### Run tests + +From repository root: + +```bash +dotnet test contentstack.model.generator/contentstack.model.generator.sln +``` + +With coverage (example using coverlet collector — adjust flags to match your local tooling): + +```bash +dotnet test contentstack.model.generator/contentstack.model.generator.sln --collect:"XPlat Code Coverage" +``` + +### HTML test + coverage report + +`Scripts/generate_test_report.py` (stdlib-only, no extra Python packages) turns a `.trx` run plus its Cobertura coverage file into a single self-contained HTML report (pass/fail summary, per-class breakdown with failure details, and a code coverage table). Mirrors the pattern used in the `contentstack-dotnet` sibling repo's `Scripts/generate_enhanced_html_report.py`. + +```bash +dotnet test contentstack.model.generator.tests/contentstack.model.generator.tests.csproj \ + --logger "trx;LogFileName=test-results.trx" \ + --collect:"XPlat Code Coverage" \ + --results-directory contentstack.model.generator.tests/TestResults + +python3 Scripts/generate_test_report.py +``` +Run with no arguments, it auto-discovers the newest `.trx` and `coverage.cobertura.xml` under `contentstack.model.generator.tests/TestResults/`. Pass explicit paths (`python3 Scripts/generate_test_report.py `) to target a specific run. Output is `test-report_.html` at the repo root — gitignored, local artifact only. + +Run a **single test** or class (examples): + +```bash +dotnet test contentstack.model.generator/contentstack.model.generator.sln --filter "FullyQualifiedName~ContentstackClientTests" +dotnet test contentstack.model.generator/contentstack.model.generator.sln --filter "FullyQualifiedName~Constructor_ShouldInitializeWithOAuthOptions" +``` + +### Adding new tests (step by step) + +#### 1. Pick where the test lives + +| If you are testing… | Prefer | +| --- | --- | +| Existing area in the map below | Add methods to the **existing** `*Tests.cs` file (keeps related cases together). | +| A new subsystem (new public type, new vertical) | Add a **new** file under `contentstack.model.generator.tests/`, e.g. `MyFeatureTests.cs`, and update the **Test file map** table in this skill in the same PR. | +| `internal` types in the main project | Rely on **`InternalsVisibleTo`** (`contentstack.model.generator.tests`) — you can reference internals from the test assembly without changing production code for visibility. | + +#### 2. Namespace and class shape + +- **Namespace:** `contentstack.model.generator.tests` (same as existing tests). +- **Class name:** suffix **`Tests`**, e.g. `ContentstackClientTests`, `OAuthServiceTests`. +- **Regions (optional):** In large files, use `#region` blocks (e.g. `#region Property Tests`) like [ModelGeneratorTests.cs](../../contentstack.model.generator.tests/ModelGeneratorTests.cs). + +#### 3. Test method naming + +Follow patterns already used in this repo: + +- **`MethodName_Scenario_ExpectedOutcome`** — e.g. `Constructor_ShouldInitializeWithOAuthOptions`, `GenerateCodeVerifier_ShouldReturnValidBase64UrlEncodedString`. +- Or **`Feature_ShouldBehavior`** — e.g. `ApiKey_ShouldBeRequired`, `OAuthScopesParsing_ShouldParseCorrectly`. + +Names should read clearly in test runners and failure output. + +#### 4. xUnit attributes + +| Use | When | +| --- | --- | +| `[Fact]` | Single scenario, no input matrix. | +| `[Theory]` + `[InlineData(...)]` | Parameterized cases (multiple inputs → same assertion shape). | +| `[Theory]` + `[MemberData(nameof(MyData))]` | Larger or shared datasets (use when InlineData becomes unwieldy). | + +**Exceptions:** use `Assert.Throws(() => ...)` (see `Constructor_ShouldHandleNullOptions` in [ContentstackClientTests.cs](../../contentstack.model.generator.tests/ContentstackClientTests.cs)). + +**Async:** use `public async Task MethodName_...()` and `await` inside; xUnit supports async test methods. + +#### 5. Arrange / Act / Assert + +Keep the same structure as existing files: + +1. **Arrange** — build `ContentstackOptions`, `ModelGenerator`, mocks, or test data (use obvious **placeholder** strings like `test_api_key`, not real credentials). +2. **Act** — call the method under test (or construct the object). +3. **Assert** — `Assert.Equal`, `Assert.NotNull`, `Assert.True`, etc. + +Leave a blank line between phases if it helps readability (many tests in this repo separate blocks with spacing). + +#### 6. Moq and HTTP + +- Use **Moq** when you need substitutes for interfaces or virtual members; follow imports and style in [OAuthServiceTests.cs](../../contentstack.model.generator.tests/OAuthServiceTests.cs) and other OAuth/HTTP tests. +- For **HTTP**, prefer mocking at the boundary you own (e.g. handlers) rather than hitting real network URLs in unit tests. + +#### 7. CLI / `ModelGenerator` options + +- For option metadata and validation, tests often use **reflection** (`typeof(ModelGenerator).GetProperty`, `GetCustomAttribute()`, `OptionAttribute`) — see [ModelGeneratorTests.cs](../../contentstack.model.generator.tests/ModelGeneratorTests.cs). +- When simulating CLI behavior, you can set properties on `new ModelGenerator()` directly; full end-to-end CLI execution is heavier — keep tests focused and fast unless you add a dedicated integration test. + +#### 8. Before you open a PR + +- Run the full suite: `dotnet test contentstack.model.generator/contentstack.model.generator.sln`. +- **CI does not run** `dotnet test` in GitHub Actions today — local green tests are the gate (see [dev-workflow/SKILL.md](../dev-workflow/SKILL.md)). +- Re-read [Credentials and secrets](#credentials-and-secrets) below — no real tokens or keys in source. + +### Test file map + +| File | Typical focus | +| --- | --- | +| [ContentstackOptionsTests.cs](../../contentstack.model.generator.tests/ContentstackOptionsTests.cs) | `ContentstackOptions` construction and options. | +| [ContentstackClientTests.cs](../../contentstack.model.generator.tests/ContentstackClientTests.cs) | `ContentstackClient` behavior (headers, config). | +| [HTTPRequestHandlerTests.cs](../../contentstack.model.generator.tests/HTTPRequestHandlerTests.cs) | HTTP request handling. | +| [OAuthServiceTests.cs](../../contentstack.model.generator.tests/OAuthServiceTests.cs) | OAuth helpers (PKCE URL generation, etc.). | +| [OAuthTokenExchangeTests.cs](../../contentstack.model.generator.tests/OAuthTokenExchangeTests.cs) | Token exchange flows. | +| [OAuthErrorHandlingTests.cs](../../contentstack.model.generator.tests/OAuthErrorHandlingTests.cs) | OAuth error paths. | +| [OAuthIntegrationTests.cs](../../contentstack.model.generator.tests/OAuthIntegrationTests.cs) | Broader OAuth integration scenarios. | +| [ModelGeneratorOAuthTests.cs](../../contentstack.model.generator.tests/ModelGeneratorOAuthTests.cs) | CLI / `ModelGenerator` + OAuth interactions. | +| [ModelGeneratorTests.cs](../../contentstack.model.generator.tests/ModelGeneratorTests.cs) | CLI option metadata / `ModelGenerator` property behavior. | +| [ModelGeneratorTestHelpers.cs](../../contentstack.model.generator.tests/ModelGeneratorTestHelpers.cs) | Not a test class - shared `Field`/`MetaData`/`Contenttype` fixture builders and `CreateGenerator(...)` factory for the codegen test files below. | +| [ModelGeneratorFieldTypeResolutionTests.cs](../../contentstack.model.generator.tests/ModelGeneratorFieldTypeResolutionTests.cs) | `GetDatatypeForField` - field-type → C# type mapping, reference/global_field resolution, multiplicity wrapping. | +| [ModelGeneratorAddParamsTests.cs](../../contentstack.model.generator.tests/ModelGeneratorAddParamsTests.cs) | `AddParams` - attribute + property emission per field type, markdown special case. | +| [ModelGeneratorNullableVariantTests.cs](../../contentstack.model.generator.tests/ModelGeneratorNullableVariantTests.cs) | `IsNullable` (`--is-nullable`/`-N`) toggle across `AddParams`/`CreateLinkClass`/`CreateFile`/`CreateModularBlockConverter`, plus the `CreateEmbeddedObjectClass` no-suffix asymmetry. | +| [ModelGeneratorCreateFileTests.cs](../../contentstack.model.generator.tests/ModelGeneratorCreateFileTests.cs) | `CreateFile` end-to-end - class shape, nested blocks/group generation, using directives, embedded-items interpolation regression. | +| [ModelGeneratorSupportingClassGenerationTests.cs](../../contentstack.model.generator.tests/ModelGeneratorSupportingClassGenerationTests.cs) | `CreateLinkClass`, `CreateEmbeddedObjectClass`, `CreateModularBlockConverter` called directly. | + +### Credentials and secrets + +- **Do not** commit real stack API keys, authtokens, client secrets, or production OAuth codes. +- Prefer **mocks** (Moq) and **fake/staging** endpoints where integration tests need HTTP; keep secrets in CI **encrypted secrets**, not in source. +- Vulnerability reporting: [SECURITY.md](../../SECURITY.md) (do not file security issues as public GitHub issues).