From 899905cae3d73a1760d1c82f02316302d1b87914 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Tue, 30 Sep 2025 12:56:00 +0530 Subject: [PATCH 1/5] Feat: Added OAuth Support In Dotnet Model Generator and version bump and readme update --- CHANGELOG.md | 5 + README.md | 133 +++++- .../ContentstackClientTests.cs | 292 ++++++++++++ .../ContentstackOptionsTests.cs | 240 ++++++++++ .../HTTPRequestHandlerTests.cs | 220 +++++++++ .../ModelGeneratorOAuthTests.cs | 266 +++++++++++ .../ModelGeneratorTests.cs | 383 +++++++++++++++ .../OAuthErrorHandlingTests.cs | 374 +++++++++++++++ .../OAuthIntegrationTests.cs | 321 +++++++++++++ .../OAuthServiceTests.cs | 290 ++++++++++++ .../OAuthTokenExchangeTests.cs | 82 ++++ .../contentstack.model.generator.tests.csproj | 27 ++ .../CMA/ContentstackClient.cs | 15 +- .../CMA/ContentstackOptions.cs | 14 + .../CMA/HTTPRequestHandler.cs | 199 ++++---- .../CMA/Http/ContentstackHttpRequest.cs | 199 ++++++++ .../CMA/Http/ContentstackResponse.cs | 202 ++++++++ .../CMA/Http/IHttpRequest.cs | 52 +++ .../CMA/Http/IResponse.cs | 27 ++ .../CMA/OAuth/OAuthService.cs | 435 ++++++++++++++++++ .../ModelGenerator.cs | 208 +++++++-- .../contentstack.model.generator.csproj | 162 +++---- 22 files changed, 3946 insertions(+), 200 deletions(-) create mode 100644 contentstack.model.generator.tests/ContentstackClientTests.cs create mode 100644 contentstack.model.generator.tests/ContentstackOptionsTests.cs create mode 100644 contentstack.model.generator.tests/HTTPRequestHandlerTests.cs create mode 100644 contentstack.model.generator.tests/ModelGeneratorOAuthTests.cs create mode 100644 contentstack.model.generator.tests/ModelGeneratorTests.cs create mode 100644 contentstack.model.generator.tests/OAuthErrorHandlingTests.cs create mode 100644 contentstack.model.generator.tests/OAuthIntegrationTests.cs create mode 100644 contentstack.model.generator.tests/OAuthServiceTests.cs create mode 100644 contentstack.model.generator.tests/OAuthTokenExchangeTests.cs create mode 100644 contentstack.model.generator.tests/contentstack.model.generator.tests.csproj create mode 100644 contentstack.model.generator/CMA/Http/ContentstackHttpRequest.cs create mode 100644 contentstack.model.generator/CMA/Http/ContentstackResponse.cs create mode 100644 contentstack.model.generator/CMA/Http/IHttpRequest.cs create mode 100644 contentstack.model.generator/CMA/Http/IResponse.cs create mode 100644 contentstack.model.generator/CMA/OAuth/OAuthService.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index dec5a6f..6dc19b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +### Version: 0.5.0 +#### Date: Oct-05-2025 + +- Feat: Added OAuth Support in Dotnet Model Generator + ### Version: 0.4.6 #### Date: Feb-19-2024 diff --git a/README.md b/README.md index e7e8060..6a8486b 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,19 @@ dotnet tool install -g contentstack.model.generator ## How to use Once you install ```contentstack.model.generator``` run ```--help``` to view available commands. +### Authentication Methods + +The Contentstack Model Generator supports two authentication methods: + +1. **Traditional API Key Authentication** (default) +2. **OAuth 2.0 Authentication** + +### Command Line Options + | Short key | Long Key | Description | | -- | -- | -- | | `-a` | `--api-key` | The Stack API key for the Content Management API | -| `-A` | `--authtoken` | The Authtoken for the Content Management API | +| `-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 | @@ -25,33 +34,143 @@ Once you install ```contentstack.model.generator``` run ```--help``` to view av | `-g` | `--group-prefix` | The Group Class Prefix. | | `-p` | `--path` | Path to the file or directory to create files in. | -### Example 1 +### OAuth 2.0 Options + +| Long Key | Description | +| -- | -- | +| `--oauth` | Enable OAuth 2.0 authentication (mutually exclusive with traditional auth) | +| `--client-id` | OAuth Client ID (required for OAuth) (Default Value: Ie0FEfTzlfAHL4xM ) | +| `--client-secret` | OAuth Client Secret (optional for public clients using PKCE) | +| `--redirect-uri` | OAuth Redirect URI (required for OAuth) (Default Value: http://localhost:8184 ) | +| `--app-id` | OAuth App ID (optional) ( Default Value: 6400aa06db64de001a31c8a9 ) | +| `--scopes` | OAuth Scopes (optional, space-separated) | + +## Examples + +### Traditional API Key Authentication + +#### Example 1: Basic Usage To create classes in current directory run following command: ``` contentstack.model.generator -a -A ``` -### Example 2 +#### Example 2: Specific Path To create classes in specific path run following command: ``` contentstack.model.generator -a -A -p /User/xxx/Desktop ``` -### Example 3 +#### Example 3: With Namespace To create classes with namespace run following command: ``` contentstack.model.generator -a -A -n YourProject.Models ``` -### Example 4 -To allow `Nullable` annotation context in model creation run following command +#### Example 4: With Nullable Annotations +To allow `Nullable` annotation context in model creation run following command: ``` contentstack.model.generator -a -A -N ``` +### OAuth 2.0 Authentication + +#### Example 5: OAuth with PKCE (Recommended) +For public clients or enhanced security, use OAuth with PKCE: +``` +contentstack.model.generator --oauth -a --client-id --redirect-uri http://localhost:8184 +``` + +#### Example 6: OAuth with Client Secret +For confidential clients with client secret: +``` +contentstack.model.generator --oauth -a --client-id --client-secret --redirect-uri http://localhost:8184 +``` + +#### Example 7: OAuth with App ID +For OAuth with specific app: +``` +contentstack.model.generator --oauth -a --client-id --redirect-uri http://localhost:8184 --app-id +``` + +#### Example 8: OAuth with Custom Path and Namespace +``` +contentstack.model.generator --oauth -a --client-id --redirect-uri http://localhost:8184 -p /path/to/models -n YourProject.Models +``` + +## OAuth Command Example + +Here's what you'll see when running an OAuth command: + +```bash + +$ contentstack.model.generator --oauth -a --client-id myclient123 --redirect-uri http://localhost:8184 + +Contentstack Model Generator v0.4.6 +===================================== + +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=... + +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] + +Exchanging authorization code for access token... +OAuth authentication successful! +Access token expires at: 2024-01-15 14:30:00 UTC + +Fetching stack information... +Stack: My Contentstack Stack +API Key: api_key + +Fetching content types... +Found 5 content types: +Generating files from content type + +Files successfully created! +Opening /Models + +Logging out from OAuth... +OAuth logout successful! +``` + +## OAuth 2.0 Setup + +### Prerequisites +1. **Contentstack Account**: You need a Contentstack account with appropriate permissions +2. **OAuth App**: Create an OAuth application in your Contentstack dashboard +3. **Redirect URI**: Configure a valid redirect URI (e.g., `http://localhost:8184`) + +### OAuth Flow +1. **Authorization**: The tool displays the Contentstack OAuth authorization URL for you to open manually +2. **Authentication**: Open the URL in your browser, log in to your Contentstack account and authorize the application +3. **Callback**: You'll be redirected to your specified redirect URI with an authorization code +4. **Code Entry**: Copy the authorization code from the redirect URL and paste it into the tool +5. **Token Exchange**: The tool automatically exchanges the code for an access token +6. **Model Generation**: The tool fetches your content types and generates models +7. **Logout**: The tool automatically logs out and clears tokens + +### Security Features +- **PKCE Support**: Uses Proof Key for Code Exchange for enhanced security +- **Client Secret Optional**: Supports both confidential and public clients +- **Automatic Token Management**: Handles token refresh and expiration +- **Secure Logout**: Automatically clears tokens after model generation + +### Troubleshooting OAuth +- **Invalid Redirect URI**: Ensure the redirect URI matches exactly what's configured in your OAuth app +- **Client ID/Secret Issues**: Verify your OAuth app credentials +- **Network Issues**: Check your internet connection and Contentstack service status +- **Permission Issues**: Ensure your account has the necessary permissions for the stack + ### MIT License -Copyright (c) 2012-2024 Contentstack +Copyright (c) 2012-2025 Contentstack Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/contentstack.model.generator.tests/ContentstackClientTests.cs b/contentstack.model.generator.tests/ContentstackClientTests.cs new file mode 100644 index 0000000..677e080 --- /dev/null +++ b/contentstack.model.generator.tests/ContentstackClientTests.cs @@ -0,0 +1,292 @@ +using System; +using System.Collections.Generic; +using Xunit; +using contentstack.CMA; + +namespace contentstack.model.generator.tests +{ + public class ContentstackClientTests + { + [Fact] + public void Constructor_ShouldInitializeWithOAuthOptions() + { + + var options = new ContentstackOptions + { + ApiKey = "test_api_key", + Host = "api.contentstack.io", + IsOAuth = true, + Authorization = "Bearer test_access_token", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id" + }; + + + var client = new ContentstackClient(options); + + + Assert.NotNull(client); + Assert.Equal("test_api_key", client.StackApiKey); + } + + [Fact] + public void Constructor_ShouldInitializeWithTraditionalAuth() + { + + var options = new ContentstackOptions + { + ApiKey = "test_api_key", + Host = "api.contentstack.io", + IsOAuth = false, + Authtoken = "test_authtoken" + }; + + + var client = new ContentstackClient(options); + + + Assert.NotNull(client); + Assert.Equal("test_api_key", client.StackApiKey); + } + + [Fact] + public void Constructor_ShouldHandleNullOptions() + { + + ContentstackOptions options = null; + Assert.Throws(() => new ContentstackClient(options)); + } + + [Fact] + public void Constructor_ShouldSetSerializerSettings() + { + + var options = new ContentstackOptions + { + ApiKey = "test_api_key", + Host = "api.contentstack.io" + }; + + + var client = new ContentstackClient(options); + + + Assert.NotNull(client.SerializerSettings); + } + + [Theory] + [InlineData("api.contentstack.io")] + [InlineData("https://api.contentstack.io")] + [InlineData("http://api.contentstack.io")] + public void Constructor_ShouldPreserveHostFormat(string inputHost) + { + + var options = new ContentstackOptions + { + ApiKey = "test_api_key", + Host = inputHost + }; + + + var client = new ContentstackClient(options); + + + Assert.NotNull(client); + } + + [Fact] + public void GetHeader_ShouldReturnLocalHeadersWhenMainHeadersIsNull() + { + + var options = new ContentstackOptions + { + ApiKey = "test_api_key", + Host = "api.contentstack.io" + }; + var client = new ContentstackClient(options); + var localHeaders = new Dictionary + { + { "header1", "value1" }, + { "header2", "value2" } + }; + + + var result = client.GetHeader(localHeaders); + + + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Equal("value1", result["header1"]); + Assert.Equal("value2", result["header2"]); + } + + [Fact] + public void GetHeader_ShouldReturnMainHeadersWhenLocalHeadersIsNull() + { + + var options = new ContentstackOptions + { + ApiKey = "test_api_key", + Host = "api.contentstack.io" + }; + var client = new ContentstackClient(options); + + + var result = client.GetHeader(null); + + + Assert.NotNull(result); + // Should return main headers (StackHeaders) + } + + [Fact] + public void GetHeader_ShouldMergeLocalAndMainHeaders() + { + + var options = new ContentstackOptions + { + ApiKey = "test_api_key", + Host = "api.contentstack.io" + }; + var client = new ContentstackClient(options); + var localHeaders = new Dictionary + { + { "local_header", "local_value" }, + { "api_key", "local_api_key" } // This should override main header + }; + + + var result = client.GetHeader(localHeaders); + + + Assert.NotNull(result); + Assert.Contains("local_header", result.Keys); + Assert.Equal("local_value", result["local_header"]); + } + + [Fact] + public void GetHeader_ShouldPrioritizeLocalHeadersOverMainHeaders() + { + + var options = new ContentstackOptions + { + ApiKey = "test_api_key", + Host = "api.contentstack.io" + }; + var client = new ContentstackClient(options); + var localHeaders = new Dictionary + { + { "api_key", "overridden_api_key" } + }; + + + var result = client.GetHeader(localHeaders); + + + Assert.NotNull(result); + Assert.Equal("overridden_api_key", result["api_key"]); + } + + [Fact] + public void GetHeader_ShouldHandleEmptyLocalHeaders() + { + + var options = new ContentstackOptions + { + ApiKey = "test_api_key", + Host = "api.contentstack.io" + }; + var client = new ContentstackClient(options); + var localHeaders = new Dictionary(); + + + var result = client.GetHeader(localHeaders); + + + Assert.NotNull(result); + // Should return main headers only + } + + [Fact] + public void GetHeader_ShouldHandleNullValues() + { + + var options = new ContentstackOptions + { + ApiKey = "test_api_key", + Host = "api.contentstack.io" + }; + var client = new ContentstackClient(options); + var localHeaders = new Dictionary + { + { "null_header", null }, + { "empty_header", "" } + }; + + + var result = client.GetHeader(localHeaders); + + + Assert.NotNull(result); + Assert.Contains("null_header", result.Keys); + Assert.Contains("empty_header", result.Keys); + } + + [Fact] + public void GetHeader_ShouldHandleSpecialCharacters() + { + + var options = new ContentstackOptions + { + ApiKey = "test_api_key", + Host = "api.contentstack.io" + }; + var client = new ContentstackClient(options); + var localHeaders = new Dictionary + { + { "header with spaces", "value with spaces" }, + { "header-with-dashes", "value-with-dashes" }, + { "header_with_underscores", "value_with_underscores" } + }; + + + var result = client.GetHeader(localHeaders); + + + Assert.NotNull(result); + Assert.Equal(3, result.Count); + Assert.Equal("value with spaces", result["header with spaces"]); + Assert.Equal("value-with-dashes", result["header-with-dashes"]); + Assert.Equal("value_with_underscores", result["header_with_underscores"]); + } + + [Fact] + public void GetHeader_ShouldHandleCaseSensitiveKeys() + { + + var options = new ContentstackOptions + { + ApiKey = "test_api_key", + Host = "api.contentstack.io" + }; + var client = new ContentstackClient(options); + var localHeaders = new Dictionary + { + { "Header1", "Value1" }, + { "header1", "value1" }, + { "HEADER1", "VALUE1" } + }; + + + var result = client.GetHeader(localHeaders); + + + Assert.NotNull(result); + Assert.Equal(3, result.Count); // All should be treated as different keys + Assert.Equal("Value1", result["Header1"]); + Assert.Equal("value1", result["header1"]); + Assert.Equal("VALUE1", result["HEADER1"]); + } + } +} diff --git a/contentstack.model.generator.tests/ContentstackOptionsTests.cs b/contentstack.model.generator.tests/ContentstackOptionsTests.cs new file mode 100644 index 0000000..53c2d93 --- /dev/null +++ b/contentstack.model.generator.tests/ContentstackOptionsTests.cs @@ -0,0 +1,240 @@ +using System; +using Xunit; +using contentstack.CMA; + +namespace contentstack.model.generator.tests +{ + public class ContentstackOptionsTests + { + [Fact] + public void Constructor_ShouldInitializeWithDefaultValues() + { + + var options = new ContentstackOptions(); + Assert.Null(options.ApiKey); + Assert.Null(options.Authtoken); + Assert.Null(options.Host); + Assert.Null(options.Branch); + Assert.Null(options.Version); + Assert.False(options.IsOAuth); + Assert.Null(options.Authorization); + Assert.Null(options.OAuthClientId); + Assert.Null(options.OAuthClientSecret); + Assert.Null(options.OAuthRedirectUri); + Assert.Null(options.OAuthAppId); + Assert.Null(options.OAuthScopes); + Assert.Null(options.AccessToken); + Assert.Null(options.RefreshToken); + Assert.Null(options.TokenExpiresAt); + } + + [Fact] + public void Properties_ShouldBeSettableAndGettable() + { + + var options = new ContentstackOptions(); + var testDate = DateTime.UtcNow.AddHours(1); + var testScopes = new[] { "read", "write" }; + options.ApiKey = "test_api_key"; + options.Authtoken = "test_authtoken"; + options.Host = "api.contentstack.io"; + options.Branch = "main"; + options.Version = "v3"; + options.IsOAuth = true; + options.Authorization = "Bearer test_token"; + options.OAuthClientId = "test_client_id"; + options.OAuthClientSecret = "test_client_secret"; + options.OAuthRedirectUri = "http://localhost:8080"; + options.OAuthAppId = "test_app_id"; + options.OAuthScopes = testScopes; + options.AccessToken = "test_access_token"; + options.RefreshToken = "test_refresh_token"; + options.TokenExpiresAt = testDate; + Assert.Equal("test_api_key", options.ApiKey); + Assert.Equal("test_authtoken", options.Authtoken); + Assert.Equal("api.contentstack.io", options.Host); + Assert.Equal("main", options.Branch); + Assert.Equal("v3", options.Version); + Assert.True(options.IsOAuth); + Assert.Equal("Bearer test_token", options.Authorization); + Assert.Equal("test_client_id", options.OAuthClientId); + Assert.Equal("test_client_secret", options.OAuthClientSecret); + Assert.Equal("http://localhost:8080", options.OAuthRedirectUri); + Assert.Equal("test_app_id", options.OAuthAppId); + Assert.Equal(testScopes, options.OAuthScopes); + Assert.Equal("test_access_token", options.AccessToken); + Assert.Equal("test_refresh_token", options.RefreshToken); + Assert.Equal(testDate, options.TokenExpiresAt); + } + + [Fact] + public void OAuthScopes_ShouldHandleNullValue() + { + var options = new ContentstackOptions(); + options.OAuthScopes = null; + Assert.Null(options.OAuthScopes); + } + + [Fact] + public void OAuthScopes_ShouldHandleEmptyArray() + { + + var options = new ContentstackOptions(); + var emptyScopes = new string[0]; + options.OAuthScopes = emptyScopes; + Assert.NotNull(options.OAuthScopes); + Assert.Empty(options.OAuthScopes); + } + + [Fact] + public void OAuthScopes_ShouldHandleSingleScope() + { + + var options = new ContentstackOptions(); + var singleScope = new[] { "read" }; + options.OAuthScopes = singleScope; + Assert.NotNull(options.OAuthScopes); + Assert.Single(options.OAuthScopes); + Assert.Equal("read", options.OAuthScopes[0]); + } + + [Fact] + public void OAuthScopes_ShouldHandleMultipleScopes() + { + + var options = new ContentstackOptions(); + var multipleScopes = new[] { "read", "write", "admin" }; + options.OAuthScopes = multipleScopes; + Assert.NotNull(options.OAuthScopes); + Assert.Equal(3, options.OAuthScopes.Length); + Assert.Equal("read", options.OAuthScopes[0]); + Assert.Equal("write", options.OAuthScopes[1]); + Assert.Equal("admin", options.OAuthScopes[2]); + } + + [Fact] + public void OAuthScopes_ShouldHandleScopesWithSpaces() + { + + var options = new ContentstackOptions(); + var scopesWithSpaces = new[] { "read content", "write content", "manage users" }; + options.OAuthScopes = scopesWithSpaces; + Assert.NotNull(options.OAuthScopes); + Assert.Equal(3, options.OAuthScopes.Length); + Assert.Equal("read content", options.OAuthScopes[0]); + Assert.Equal("write content", options.OAuthScopes[1]); + Assert.Equal("manage users", options.OAuthScopes[2]); + } + + [Fact] + public void OAuthScopes_ShouldHandleEmptyStringScopes() + { + + var options = new ContentstackOptions(); + var scopesWithEmpty = new[] { "read", "", "write" }; + options.OAuthScopes = scopesWithEmpty; + Assert.NotNull(options.OAuthScopes); + Assert.Equal(3, options.OAuthScopes.Length); + Assert.Equal("read", options.OAuthScopes[0]); + Assert.Equal("", options.OAuthScopes[1]); + Assert.Equal("write", options.OAuthScopes[2]); + } + + [Fact] + public void OAuthScopes_ShouldHandleNullStringScopes() + { + + var options = new ContentstackOptions(); + var scopesWithNull = new[] { "read", null, "write" }; + options.OAuthScopes = scopesWithNull; + Assert.NotNull(options.OAuthScopes); + Assert.Equal(3, options.OAuthScopes.Length); + Assert.Equal("read", options.OAuthScopes[0]); + Assert.Null(options.OAuthScopes[1]); + Assert.Equal("write", options.OAuthScopes[2]); + } + + [Fact] + public void TokenExpiresAt_ShouldHandleNullValue() + { + + var options = new ContentstackOptions(); + options.TokenExpiresAt = null; + Assert.Null(options.TokenExpiresAt); + } + + [Fact] + public void TokenExpiresAt_ShouldHandleDateTimeValue() + { + + var options = new ContentstackOptions(); + var testDate = DateTime.UtcNow.AddHours(2); + options.TokenExpiresAt = testDate; + Assert.Equal(testDate, options.TokenExpiresAt); + } + + [Fact] + public void TokenExpiresAt_ShouldHandleDateTimeKind() + { + + var options = new ContentstackOptions(); + var utcDate = DateTime.UtcNow.AddHours(1); + var localDate = DateTime.Now.AddHours(1); + options.TokenExpiresAt = utcDate; + Assert.Equal(DateTimeKind.Utc, options.TokenExpiresAt.Value.Kind); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("test_value")] + public void StringProperties_ShouldHandleVariousValues(string testValue) + { + + var options = new ContentstackOptions(); + options.ApiKey = testValue; + options.Authtoken = testValue; + options.Host = testValue; + options.Branch = testValue; + options.Version = testValue; + options.Authorization = testValue; + options.OAuthClientId = testValue; + options.OAuthClientSecret = testValue; + options.OAuthRedirectUri = testValue; + options.OAuthAppId = testValue; + options.AccessToken = testValue; + options.RefreshToken = testValue; + Assert.Equal(testValue, options.ApiKey); + Assert.Equal(testValue, options.Authtoken); + Assert.Equal(testValue, options.Host); + Assert.Equal(testValue, options.Branch); + Assert.Equal(testValue, options.Version); + Assert.Equal(testValue, options.Authorization); + Assert.Equal(testValue, options.OAuthClientId); + Assert.Equal(testValue, options.OAuthClientSecret); + Assert.Equal(testValue, options.OAuthRedirectUri); + Assert.Equal(testValue, options.OAuthAppId); + Assert.Equal(testValue, options.AccessToken); + Assert.Equal(testValue, options.RefreshToken); + } + + [Fact] + public void IsOAuth_ShouldDefaultToFalse() + { + var options = new ContentstackOptions(); + Assert.False(options.IsOAuth); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void IsOAuth_ShouldBeSettable(bool value) + { + var options = new ContentstackOptions(); + options.IsOAuth = value; + Assert.Equal(value, options.IsOAuth); + } + } +} + + diff --git a/contentstack.model.generator.tests/HTTPRequestHandlerTests.cs b/contentstack.model.generator.tests/HTTPRequestHandlerTests.cs new file mode 100644 index 0000000..e099a26 --- /dev/null +++ b/contentstack.model.generator.tests/HTTPRequestHandlerTests.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; +using Moq; +using contentstack.CMA; + +namespace contentstack.model.generator.tests +{ + public class HTTPRequestHandlerTests : IDisposable + { + private readonly HttpRequestHandler _handler; + + public HTTPRequestHandlerTests() + { + _handler = new HttpRequestHandler(); + } + + [Fact] + public void Constructor_ShouldInitializeHttpClient() + { + var handler = new HttpRequestHandler(); + Assert.NotNull(handler); + } + + [Fact] + public async Task ProcessRequest_ShouldHandleOAuthBearerToken() + { + + var url = "https://api.contentstack.io/v3/stacks"; + var headers = new Dictionary + { + { "Authorization", "Bearer test_access_token_123" }, + { "api_key", "test_api_key" } + }; + var bodyJson = new Dictionary + { + { "param1", "value1" }, + { "param2", "value2" } + }; + + await Assert.ThrowsAsync(() => + _handler.ProcessRequest(url, headers, bodyJson)); + } + + [Fact] + public async Task ProcessRequest_ShouldHandleBearerTokenWithoutPrefix() + { + + var url = "https://api.contentstack.io/v3/stacks"; + var headers = new Dictionary + { + { "Authorization", "test_access_token_123" }, // No "Bearer " prefix + { "api_key", "test_api_key" } + }; + var bodyJson = new Dictionary + { + { "param1", "value1" } + }; + + await Assert.ThrowsAsync(() => + _handler.ProcessRequest(url, headers, bodyJson)); + } + + [Fact] + public async Task ProcessRequest_ShouldHandleEmptyHeaders() + { + + var url = "https://api.contentstack.io/v3/stacks"; + var headers = new Dictionary(); + var bodyJson = new Dictionary + { + { "param1", "value1" } + }; + + await Assert.ThrowsAsync(() => + _handler.ProcessRequest(url, headers, bodyJson)); + } + + [Fact] + public async Task ProcessRequest_ShouldHandleNullHeaders() + { + + var url = "https://api.contentstack.io/v3/stacks"; + Dictionary headers = null; + var bodyJson = new Dictionary + { + { "param1", "value1" } + }; + + await Assert.ThrowsAsync(() => + _handler.ProcessRequest(url, headers, bodyJson)); + } + + [Fact] + public async Task ProcessRequest_ShouldHandleStringArrayValues() + { + + var url = "https://api.contentstack.io/v3/stacks"; + var headers = new Dictionary + { + { "api_key", "test_api_key" } + }; + var bodyJson = new Dictionary + { + { "array_param", new[] { "value1", "value2", "value3" } } + }; + + await Assert.ThrowsAsync(() => + _handler.ProcessRequest(url, headers, bodyJson)); + } + + [Fact] + public async Task ProcessRequest_ShouldHandleDictionaryValues() + { + + var url = "https://api.contentstack.io/v3/stacks"; + var headers = new Dictionary + { + { "api_key", "test_api_key" } + }; + var bodyJson = new Dictionary + { + { "dict_param", new Dictionary { { "key1", "value1" } } } + }; + + await Assert.ThrowsAsync(() => + _handler.ProcessRequest(url, headers, bodyJson)); + } + + [Fact] + public async Task ProcessRequest_ShouldHandleEmptyBodyJson() + { + + var url = "https://api.contentstack.io/v3/stacks"; + var headers = new Dictionary + { + { "api_key", "test_api_key" } + }; + var bodyJson = new Dictionary(); + + await Assert.ThrowsAsync(() => + _handler.ProcessRequest(url, headers, bodyJson)); + } + + [Fact] + public async Task ProcessRequest_ShouldHandleNullBodyJson() + { + + var url = "https://api.contentstack.io/v3/stacks"; + var headers = new Dictionary + { + { "api_key", "test_api_key" } + }; + Dictionary bodyJson = null; + + await Assert.ThrowsAsync(() => + _handler.ProcessRequest(url, headers, bodyJson)); + } + + [Fact] + public async Task ProcessRequest_ShouldHandleInvalidUrl() + { + + var url = "invalid-url"; + var headers = new Dictionary + { + { "api_key", "test_api_key" } + }; + var bodyJson = new Dictionary + { + { "param1", "value1" } + }; + + await Assert.ThrowsAsync(() => + _handler.ProcessRequest(url, headers, bodyJson)); + } + + [Fact] + public async Task ProcessRequest_ShouldHandleSpecialCharactersInParameters() + { + + var url = "https://api.contentstack.io/v3/stacks"; + var headers = new Dictionary + { + { "api_key", "test_api_key" } + }; + var bodyJson = new Dictionary + { + { "param with spaces", "value with spaces" }, + { "param&with&special", "value&with&special" }, + { "param=with=equals", "value=with=equals" } + }; + + await Assert.ThrowsAsync(() => + _handler.ProcessRequest(url, headers, bodyJson)); + } + + [Fact] + public void Dispose_ShouldNotThrow() + { + var exception = Record.Exception(() => _handler.Dispose()); + Assert.Null(exception); + } + + [Fact] + public void Dispose_ShouldBeIdempotent() + { + _handler.Dispose(); + var exception = Record.Exception(() => _handler.Dispose()); + Assert.Null(exception); + } + + public void Dispose() + { + _handler?.Dispose(); + } + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorOAuthTests.cs b/contentstack.model.generator.tests/ModelGeneratorOAuthTests.cs new file mode 100644 index 0000000..b92db83 --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorOAuthTests.cs @@ -0,0 +1,266 @@ +using System; +using System.Linq; +using Xunit; +using McMaster.Extensions.CommandLineUtils; +using contentstack.CMA; + +namespace contentstack.model.generator.tests +{ + public class ModelGeneratorOAuthTests + { + [Fact] + public void OAuthOptions_ShouldBeDefined() + { + + var app = new CommandLineApplication(); + var modelGenerator = new ModelGenerator(); + app.Command("test", cmd => + { + cmd.Option("--oauth", "Use OAuth authentication", CommandOptionType.NoValue); + cmd.Option("--client-id", "OAuth Client ID", CommandOptionType.SingleValue); + cmd.Option("--client-secret", "OAuth Client Secret", CommandOptionType.SingleValue); + cmd.Option("--redirect-uri", "OAuth Redirect URI", CommandOptionType.SingleValue); + cmd.Option("--app-id", "OAuth App ID", CommandOptionType.SingleValue); + cmd.Option("--scopes", "OAuth Scopes", CommandOptionType.SingleValue); + }); + + + Assert.True(modelGenerator.UseOAuth == false); // Default value + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleNullInput() + { + + string input = null; + string[] result = null; + if (!string.IsNullOrEmpty(input)) + { + result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + } + Assert.Null(result); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleEmptyInput() + { + string input = ""; + string[] result = null; + if (!string.IsNullOrEmpty(input)) + { + result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + } + Assert.Null(result); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleWhitespaceInput() + { + + string input = " "; + string[] result = null; + if (!string.IsNullOrEmpty(input)) + { + result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + } + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleSingleScope() + { + string input = "read"; + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Single(result); + Assert.Equal("read", result[0]); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleMultipleScopes() + { + string input = "read write admin"; + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal("read", result[0]); + Assert.Equal("write", result[1]); + Assert.Equal("admin", result[2]); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleScopesWithExtraSpaces() + { + string input = " read write admin "; + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal("read", result[0]); + Assert.Equal("write", result[1]); + Assert.Equal("admin", result[2]); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleScopesWithSpecialCharacters() + { + string input = "read content write content manage users"; + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Equal(6, result.Length); + Assert.Equal("read", result[0]); + Assert.Equal("content", result[1]); + Assert.Equal("write", result[2]); + Assert.Equal("content", result[3]); + Assert.Equal("manage", result[4]); + Assert.Equal("users", result[5]); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleEmptyScopes() + { + string input = "read write"; // Double space + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Equal(2, result.Length); + Assert.Equal("read", result[0]); + Assert.Equal("write", result[1]); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleMixedCaseScopes() + { + string input = "Read Write Admin"; + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal("Read", result[0]); + Assert.Equal("Write", result[1]); + Assert.Equal("Admin", result[2]); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleNumericScopes() + { + + string input = "scope1 scope2 scope3"; + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal("scope1", result[0]); + Assert.Equal("scope2", result[1]); + Assert.Equal("scope3", result[2]); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleScopesWithUnderscores() + { + string input = "read_content write_content manage_users"; + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal("read_content", result[0]); + Assert.Equal("write_content", result[1]); + Assert.Equal("manage_users", result[2]); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleScopesWithDashes() + { + string input = "read-content write-content manage-users"; + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal("read-content", result[0]); + Assert.Equal("write-content", result[1]); + Assert.Equal("manage-users", result[2]); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleVeryLongScopeList() + { + string input = "read write admin manage create delete update view edit publish unpublish archive restore backup"; + var expectedScopes = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Equal(expectedScopes.Length, result.Length); + Assert.Equal(expectedScopes, result); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleScopesWithNumbers() + { + string input = "read1 write2 admin3"; + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal("read1", result[0]); + Assert.Equal("write2", result[1]); + Assert.Equal("admin3", result[2]); + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleScopesWithSymbols() + { + string input = "read@content write#content manage$users"; + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Equal(3, result.Length); + Assert.Equal("read@content", result[0]); + Assert.Equal("write#content", result[1]); + Assert.Equal("manage$users", result[2]); + } + + [Theory] + [InlineData("read", 1)] + [InlineData("read write", 2)] + [InlineData("read write admin", 3)] + [InlineData(" read write admin ", 3)] + [InlineData("read1 write2 admin3", 3)] + [InlineData("read-content write-content", 2)] + [InlineData("read_content write_content", 2)] + public void OAuthScopesParsing_ShouldReturnCorrectCount(string input, int expectedCount) + { + string[] result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.NotNull(result); + Assert.Equal(expectedCount, result.Length); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void OAuthScopesParsing_ShouldHandleNullOrEmptyInput(string input) + { + + string[] result = null; + if (!string.IsNullOrEmpty(input)) + { + result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + } + + + if (string.IsNullOrEmpty(input)) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + Assert.Empty(result); + } + } + + [Fact] + public void OAuthScopesParsing_ShouldHandleNullInputValue() + { + + string input = null; + string[] result = null; + if (!string.IsNullOrEmpty(input)) + { + result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + } + Assert.Null(result); + } + } +} diff --git a/contentstack.model.generator.tests/ModelGeneratorTests.cs b/contentstack.model.generator.tests/ModelGeneratorTests.cs new file mode 100644 index 0000000..cb60ab6 --- /dev/null +++ b/contentstack.model.generator.tests/ModelGeneratorTests.cs @@ -0,0 +1,383 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Reflection; +using System.Threading.Tasks; +using contentstack.CMA; +using contentstack.model.generator; +using McMaster.Extensions.CommandLineUtils; +using Xunit; +using Moq; + +namespace contentstack.model.generator.tests +{ + public class ModelGeneratorTests + { + private readonly ModelGenerator _modelGenerator; + + public ModelGeneratorTests() + { + _modelGenerator = new ModelGenerator(); + } + + #region Property Tests + + [Fact] + public void ApiKey_ShouldBeRequired() + { + var property = typeof(ModelGenerator).GetProperty(nameof(ModelGenerator.ApiKey)); + var requiredAttribute = property.GetCustomAttribute(); + + Assert.NotNull(requiredAttribute); + Assert.Equal("You must specify the Contentstack API key for the Content Management API", requiredAttribute.ErrorMessage); + } + + [Fact] + public void UseOAuth_ShouldHaveCorrectDescription() + { + + var property = typeof(ModelGenerator).GetProperty(nameof(ModelGenerator.UseOAuth)); + var optionAttribute = property.GetCustomAttribute(); + + Assert.NotNull(optionAttribute); + Assert.Equal("Use OAuth authentication instead of traditional authtoken", optionAttribute.Description); + Assert.Equal(CommandOptionType.NoValue, optionAttribute.OptionType); + } + + [Fact] + public void OAuthAppId_ShouldHaveDefaultValue() + { + + var modelGenerator = new ModelGenerator(); + Assert.Equal("6400aa06db64de001a31c8a9", modelGenerator.OAuthAppId); + } + + [Fact] + public void OAuthClientId_ShouldHaveDefaultValue() + { + var modelGenerator = new ModelGenerator(); + Assert.Equal("Ie0FEfTzlfAHL4xM", modelGenerator.OAuthClientId); + } + + [Fact] + public void OAuthRedirectUri_ShouldHaveDefaultValue() + { + + var modelGenerator = new ModelGenerator(); + Assert.Equal("http://localhost:8184", modelGenerator.OAuthRedirectUri); + } + + [Fact] + public void OAuthScopes_ShouldBeOptional() + { + var modelGenerator = new ModelGenerator(); + Assert.Null(modelGenerator.OAuthScopes); + } + + [Fact] + public void OAuthClientSecret_ShouldBeOptional() + { + + var modelGenerator = new ModelGenerator(); + Assert.Null(modelGenerator.OAuthClientSecret); + } + + #endregion + + #region OAuth Scopes Parsing Tests + + [Theory] + [InlineData("read write admin", new[] { "read", "write", "admin" })] + [InlineData("read", new[] { "read" })] + [InlineData("", null)] + [InlineData(" ", new string[0])] + [InlineData("read content write content manage users", new[] { "read", "content", "write", "content", "manage", "users" })] + public void OAuthScopesParsing_ShouldParseCorrectly(string input, string[]? expected) + { + + _modelGenerator.OAuthScopes = input; + + + string[] result = null; + if (!string.IsNullOrEmpty(input)) + { + result = input.Split(' ', StringSplitOptions.RemoveEmptyEntries); + } + + + if (expected == null) + { + Assert.Null(result); + } + else + { + Assert.NotNull(result); + Assert.Equal(expected.Length, result.Length); + for (int i = 0; i < expected.Length; i++) + { + Assert.Equal(expected[i], result[i]); + } + } + } + + #endregion + + #region ContentstackOptions Creation Tests + + [Fact] + public void CreateContentstackOptions_WithTraditionalAuth_ShouldSetCorrectProperties() + { + + _modelGenerator.ApiKey = "test_api_key"; + _modelGenerator.Authtoken = "test_authtoken"; + _modelGenerator.Host = "api.contentstack.io"; + _modelGenerator.Branch = "main"; + _modelGenerator.UseOAuth = false; + var options = CreateContentstackOptionsFromModelGenerator(); + Assert.Equal("test_api_key", options.ApiKey); + Assert.Equal("test_authtoken", options.Authtoken); + Assert.Equal("api.contentstack.io", options.Host); + Assert.Equal("main", options.Branch); + Assert.False(options.IsOAuth); + // OAuth properties should have default values even when UseOAuth is false + Assert.Equal("Ie0FEfTzlfAHL4xM", options.OAuthClientId); + Assert.Null(options.OAuthClientSecret); + Assert.Equal("http://localhost:8184", options.OAuthRedirectUri); + Assert.Equal("6400aa06db64de001a31c8a9", options.OAuthAppId); + Assert.Null(options.OAuthScopes); + } + + [Fact] + public void CreateContentstackOptions_WithOAuth_ShouldSetCorrectProperties() + { + + _modelGenerator.ApiKey = "test_api_key"; + _modelGenerator.Host = "api.contentstack.io"; + _modelGenerator.Branch = "main"; + _modelGenerator.UseOAuth = true; + _modelGenerator.OAuthClientId = "test_client_id"; + _modelGenerator.OAuthClientSecret = "test_client_secret"; + _modelGenerator.OAuthRedirectUri = "http://localhost:8080"; + _modelGenerator.OAuthAppId = "test_app_id"; + _modelGenerator.OAuthScopes = "read write admin"; + var options = CreateContentstackOptionsFromModelGenerator(); + Assert.Equal("test_api_key", options.ApiKey); + Assert.Equal("api.contentstack.io", options.Host); + Assert.Equal("main", options.Branch); + Assert.True(options.IsOAuth); + Assert.Equal("test_client_id", options.OAuthClientId); + Assert.Equal("test_client_secret", options.OAuthClientSecret); + Assert.Equal("http://localhost:8080", options.OAuthRedirectUri); + Assert.Equal("test_app_id", options.OAuthAppId); + Assert.NotNull(options.OAuthScopes); + Assert.Equal(3, options.OAuthScopes.Length); + Assert.Equal("read", options.OAuthScopes[0]); + Assert.Equal("write", options.OAuthScopes[1]); + Assert.Equal("admin", options.OAuthScopes[2]); + } + + [Fact] + public void CreateContentstackOptions_WithOAuthAndNoScopes_ShouldSetScopesToNull() + { + + _modelGenerator.ApiKey = "test_api_key"; + _modelGenerator.Host = "api.contentstack.io"; + _modelGenerator.UseOAuth = true; + _modelGenerator.OAuthClientId = "test_client_id"; + _modelGenerator.OAuthScopes = null; + var options = CreateContentstackOptionsFromModelGenerator(); + Assert.True(options.IsOAuth); + Assert.Null(options.OAuthScopes); + } + + #endregion + + #region Validation Tests + + [Fact] + public void ApiKey_ShouldBeRequiredProperty() + { + + var property = typeof(ModelGenerator).GetProperty(nameof(ModelGenerator.ApiKey)); + var requiredAttribute = property?.GetCustomAttribute(); + Assert.NotNull(requiredAttribute); + Assert.Equal("You must specify the Contentstack API key for the Content Management API", requiredAttribute.ErrorMessage); + } + + [Fact] + public void Authtoken_ShouldNotBeRequiredProperty() + { + + var property = typeof(ModelGenerator).GetProperty(nameof(ModelGenerator.Authtoken)); + var requiredAttribute = property?.GetCustomAttribute(); + Assert.Null(requiredAttribute); + } + + #endregion + + #region Command Line Attribute Tests + + [Fact] + public void CommandAttribute_ShouldHaveCorrectProperties() + { + var commandAttribute = typeof(ModelGenerator).GetCustomAttribute(); + Assert.NotNull(commandAttribute); + Assert.Equal("contentstack.model.generator", commandAttribute.Name); + Assert.Equal("Contentstack Model Generator", commandAttribute.FullName); + Assert.Equal("Creates c# classes from a Contentstack content types.", commandAttribute.Description); + } + + [Theory] + [InlineData(nameof(ModelGenerator.ApiKey), "The Contentstack API key for the Content Management API")] + [InlineData(nameof(ModelGenerator.Authtoken), "The Authtoken for the Content Management API")] + [InlineData(nameof(ModelGenerator.Branch), "The branch header in the API request to fetch or manage modules located within specific branches.")] + [InlineData(nameof(ModelGenerator.Host), "The Contentstack Host for the Content Management API")] + [InlineData(nameof(ModelGenerator.Path), "Path to the file or directory to create files in")] + [InlineData(nameof(ModelGenerator.Namespace), "The namespace the classes should be created in")] + public void OptionAttributes_ShouldHaveCorrectDescriptions(string propertyName, string expectedDescription) + { + + var property = typeof(ModelGenerator).GetProperty(propertyName); + var optionAttribute = property.GetCustomAttribute(); + + + Assert.NotNull(optionAttribute); + Assert.Equal(expectedDescription, optionAttribute.Description); + } + + [Theory] + [InlineData(nameof(ModelGenerator.Authtoken), "A", "authtoken")] + [InlineData(nameof(ModelGenerator.Branch), "b", "branch")] + [InlineData(nameof(ModelGenerator.Host), "e", "endpoint")] + public void OptionAttributes_ShouldHaveCorrectShortAndLongNames(string propertyName, string expectedShortName, string expectedLongName) + { + + var property = typeof(ModelGenerator).GetProperty(propertyName); + var optionAttribute = property.GetCustomAttribute(); + Assert.NotNull(optionAttribute); + Assert.Equal(expectedShortName, optionAttribute.ShortName); + Assert.Equal(expectedLongName, optionAttribute.LongName); + } + + #endregion + + #region OAuth Flow Tests + + [Fact] + public void HandleOAuthFlow_WithValidOAuthOptions_ShouldNotThrow() + { + + _modelGenerator.UseOAuth = true; + _modelGenerator.ApiKey = "test_api_key"; + _modelGenerator.Host = "api.contentstack.io"; + _modelGenerator.OAuthClientId = "test_client_id"; + _modelGenerator.OAuthRedirectUri = "http://localhost:8080"; + _modelGenerator.OAuthAppId = "test_app_id"; + var options = CreateContentstackOptionsFromModelGenerator(); + Assert.True(options.IsOAuth); + Assert.Equal("test_client_id", options.OAuthClientId); + Assert.Equal("http://localhost:8080", options.OAuthRedirectUri); + Assert.Equal("test_app_id", options.OAuthAppId); + } + + #endregion + + #region Helper Methods + + private ContentstackOptions CreateContentstackOptionsFromModelGenerator() + { + // This simulates the logic from the OnExecute method + string[] oauthScopes = null; + if (!string.IsNullOrEmpty(_modelGenerator.OAuthScopes)) + { + oauthScopes = _modelGenerator.OAuthScopes.Split(' ', StringSplitOptions.RemoveEmptyEntries); + } + + return new ContentstackOptions + { + ApiKey = _modelGenerator.ApiKey, + Authtoken = _modelGenerator.Authtoken, + Host = _modelGenerator.Host, + Branch = _modelGenerator.Branch, + IsOAuth = _modelGenerator.UseOAuth, + OAuthClientId = _modelGenerator.OAuthClientId, + OAuthClientSecret = _modelGenerator.OAuthClientSecret, + OAuthRedirectUri = _modelGenerator.OAuthRedirectUri, + OAuthAppId = _modelGenerator.OAuthAppId, + OAuthScopes = oauthScopes + }; + } + + #endregion + + #region Integration Tests + + [Fact] + public void ModelGenerator_ShouldBeInstantiable() + { + + var generator = new ModelGenerator(); + + + Assert.NotNull(generator); + Assert.NotNull(generator.OAuthAppId); + Assert.NotNull(generator.OAuthClientId); + Assert.NotNull(generator.OAuthRedirectUri); + } + + [Fact] + public void ModelGenerator_ShouldHaveAllRequiredOAuthProperties() + { + + var generator = new ModelGenerator(); + Assert.True(generator.GetType().GetProperty(nameof(ModelGenerator.UseOAuth)) != null); + Assert.True(generator.GetType().GetProperty(nameof(ModelGenerator.OAuthAppId)) != null); + Assert.True(generator.GetType().GetProperty(nameof(ModelGenerator.OAuthClientId)) != null); + Assert.True(generator.GetType().GetProperty(nameof(ModelGenerator.OAuthRedirectUri)) != null); + Assert.True(generator.GetType().GetProperty(nameof(ModelGenerator.OAuthClientSecret)) != null); + Assert.True(generator.GetType().GetProperty(nameof(ModelGenerator.OAuthScopes)) != null); + } + + #endregion + + #region Edge Cases + + [Fact] + public void OAuthScopes_WithSpecialCharacters_ShouldParseCorrectly() + { + + _modelGenerator.OAuthScopes = "read:content write:content manage:users admin:all"; + string[] result = _modelGenerator.OAuthScopes.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(4, result.Length); + Assert.Equal("read:content", result[0]); + Assert.Equal("write:content", result[1]); + Assert.Equal("manage:users", result[2]); + Assert.Equal("admin:all", result[3]); + } + + [Fact] + public void OAuthScopes_WithMultipleSpaces_ShouldParseCorrectly() + { + _modelGenerator.OAuthScopes = "read write admin"; + string[] result = _modelGenerator.OAuthScopes.Split(' ', StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(3, result.Length); + Assert.Equal("read", result[0]); + Assert.Equal("write", result[1]); + Assert.Equal("admin", result[2]); + } + + [Fact] + public void OAuthScopes_WithTabsAndNewlines_ShouldParseCorrectly() + { + _modelGenerator.OAuthScopes = "read\twrite\nadmin"; + string[] result = _modelGenerator.OAuthScopes.Split(new char[] { ' ', '\t', '\n' }, StringSplitOptions.RemoveEmptyEntries); + Assert.Equal(3, result.Length); + Assert.Equal("read", result[0]); + Assert.Equal("write", result[1]); + Assert.Equal("admin", result[2]); + } + + #endregion + } +} diff --git a/contentstack.model.generator.tests/OAuthErrorHandlingTests.cs b/contentstack.model.generator.tests/OAuthErrorHandlingTests.cs new file mode 100644 index 0000000..713efa6 --- /dev/null +++ b/contentstack.model.generator.tests/OAuthErrorHandlingTests.cs @@ -0,0 +1,374 @@ +using System; +using System.Threading.Tasks; +using Xunit; +using contentstack.CMA; +using contentstack.CMA.OAuth; + +namespace contentstack.model.generator.tests +{ + public class OAuthErrorHandlingTests + { + [Fact] + public void GenerateCodeChallenge_ShouldThrowOnNullInput() + { + + string codeVerifier = null; + + + Assert.Throws(() => OAuthService.GenerateCodeChallenge(codeVerifier)); + } + + [Fact] + public void GenerateCodeChallenge_ShouldThrowOnEmptyInput() + { + + string codeVerifier = ""; + + + Assert.Throws(() => OAuthService.GenerateCodeChallenge(codeVerifier)); + } + + [Fact] + public void GenerateCodeChallenge_ShouldThrowOnWhitespaceInput() + { + + string codeVerifier = " "; + + + Assert.Throws(() => OAuthService.GenerateCodeChallenge(codeVerifier)); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldThrowOnNullOptions() + { + + ContentstackOptions options = null; + string codeVerifier = "test_verifier"; + + + Assert.Throws(() => OAuthService.GenerateAuthorizationUrl(options, codeVerifier)); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldThrowOnNullCodeVerifier() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id" + }; + string codeVerifier = null; + + + Assert.Throws(() => OAuthService.GenerateAuthorizationUrl(options, codeVerifier)); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldThrowOnEmptyCodeVerifier() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id" + }; + string codeVerifier = ""; + + + Assert.Throws(() => OAuthService.GenerateAuthorizationUrl(options, codeVerifier)); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldThrowOnNullClientId() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = null, + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id" + }; + string codeVerifier = "test_verifier"; + Assert.Throws(() => OAuthService.GenerateAuthorizationUrl(options, codeVerifier)); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldThrowOnEmptyClientId() + { + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id" + }; + string codeVerifier = "test_verifier"; + + + Assert.Throws(() => OAuthService.GenerateAuthorizationUrl(options, codeVerifier)); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldThrowOnNullRedirectUri() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = null, + OAuthAppId = "test_app_id" + }; + string codeVerifier = "test_verifier"; + + + Assert.Throws(() => OAuthService.GenerateAuthorizationUrl(options, codeVerifier)); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldThrowOnEmptyRedirectUri() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "", + OAuthAppId = "test_app_id" + }; + string codeVerifier = "test_verifier"; + + + Assert.Throws(() => OAuthService.GenerateAuthorizationUrl(options, codeVerifier)); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldThrowOnNullHost() + { + + var options = new ContentstackOptions + { + Host = null, + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id" + }; + string codeVerifier = "test_verifier"; + + + Assert.Throws(() => OAuthService.GenerateAuthorizationUrl(options, codeVerifier)); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldThrowOnEmptyHost() + { + + var options = new ContentstackOptions + { + Host = "", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id" + }; + string codeVerifier = "test_verifier"; + + + Assert.Throws(() => OAuthService.GenerateAuthorizationUrl(options, codeVerifier)); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldThrowOnNullAppId() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = null + }; + string codeVerifier = "test_verifier"; + + + Assert.Throws(() => OAuthService.GenerateAuthorizationUrl(options, codeVerifier)); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldThrowOnEmptyAppId() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "" + }; + string codeVerifier = "test_verifier"; + + + Assert.Throws(() => OAuthService.GenerateAuthorizationUrl(options, codeVerifier)); + } + + [Fact] + public void IsTokenExpired_ShouldThrowOnNullOptions() + { + + ContentstackOptions options = null; + + + Assert.Throws(() => OAuthService.IsTokenExpired(options)); + } + + [Fact] + public async Task LogoutAsync_ShouldThrowOnNullOptions() + { + + ContentstackOptions options = null; + + + await Assert.ThrowsAsync(() => OAuthService.LogoutAsync(options)); + } + + [Fact] + public void GetOAuthHost_ShouldThrowOnNullInput() + { + + string host = null; + + + Assert.Throws(() => OAuthService.GetOAuthHost(host)); + } + + [Fact] + public void GetOAuthHost_ShouldThrowOnEmptyInput() + { + + string host = ""; + + + Assert.Throws(() => OAuthService.GetOAuthHost(host)); + } + + [Fact] + public void GetDeveloperHubHostname_ShouldThrowOnNullInput() + { + + string host = null; + + + Assert.Throws(() => OAuthService.GetDeveloperHubHostname(host)); + } + + [Fact] + public void GetDeveloperHubHostname_ShouldThrowOnEmptyInput() + { + + string host = ""; + + + Assert.Throws(() => OAuthService.GetDeveloperHubHostname(host)); + } + + [Theory] + [InlineData("invalid_host")] + [InlineData("not_contentstack.com")] + [InlineData("api.other.com")] + public void GetOAuthHost_ShouldHandleInvalidHostnames(string invalidHost) + { + + // Should not throw but might return unexpected results + var result = OAuthService.GetOAuthHost(invalidHost); + Assert.NotNull(result); + } + + [Theory] + [InlineData("invalid_host")] + [InlineData("not_contentstack.com")] + [InlineData("api.other.com")] + public void GetDeveloperHubHostname_ShouldHandleInvalidHostnames(string invalidHost) + { + + // Should not throw but might return unexpected results + var result = OAuthService.GetDeveloperHubHostname(invalidHost); + Assert.NotNull(result); + } + + [Fact] + public void GenerateCodeVerifier_ShouldReturnConsistentLength() + { + + var verifier1 = OAuthService.GenerateCodeVerifier(); + var verifier2 = OAuthService.GenerateCodeVerifier(); + var verifier3 = OAuthService.GenerateCodeVerifier(); + + + Assert.True(verifier1.Length >= 43); + Assert.True(verifier1.Length <= 128); + Assert.True(verifier2.Length >= 43); + Assert.True(verifier2.Length <= 128); + Assert.True(verifier3.Length >= 43); + Assert.True(verifier3.Length <= 128); + } + + [Fact] + public void GenerateCodeChallenge_ShouldReturnConsistentLength() + { + + var codeVerifier = "test_code_verifier_123"; + + + var challenge1 = OAuthService.GenerateCodeChallenge(codeVerifier); + var challenge2 = OAuthService.GenerateCodeChallenge(codeVerifier); + + + Assert.Equal(challenge1.Length, challenge2.Length); + Assert.True(challenge1.Length > 0); + } + + [Fact] + public void GenerateCodeChallenge_ShouldHandleVeryLongCodeVerifier() + { + + var longCodeVerifier = new string('a', 200); // Very long verifier + + + // Should not throw + var challenge = OAuthService.GenerateCodeChallenge(longCodeVerifier); + Assert.NotNull(challenge); + Assert.NotEmpty(challenge); + } + + [Fact] + public void GenerateCodeChallenge_ShouldHandleSpecialCharacters() + { + + var codeVerifier = "test_verifier_with_special_chars_!@#$%^&*()"; + + + // Should not throw + var challenge = OAuthService.GenerateCodeChallenge(codeVerifier); + Assert.NotNull(challenge); + Assert.NotEmpty(challenge); + } + + [Fact] + public void GenerateCodeChallenge_ShouldHandleUnicodeCharacters() + { + + var codeVerifier = "test_verifier_with_unicode_测试_🚀"; + + + // Should not throw + var challenge = OAuthService.GenerateCodeChallenge(codeVerifier); + Assert.NotNull(challenge); + Assert.NotEmpty(challenge); + } + } +} diff --git a/contentstack.model.generator.tests/OAuthIntegrationTests.cs b/contentstack.model.generator.tests/OAuthIntegrationTests.cs new file mode 100644 index 0000000..7d4e9a2 --- /dev/null +++ b/contentstack.model.generator.tests/OAuthIntegrationTests.cs @@ -0,0 +1,321 @@ +using System; +using System.Threading.Tasks; +using Xunit; +using contentstack.CMA; +using contentstack.CMA.OAuth; + +namespace contentstack.model.generator.tests +{ + public class OAuthIntegrationTests + { + [Fact] + public void CompleteOAuthFlow_ShouldGenerateValidAuthorizationUrl() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id", + OAuthScopes = new[] { "read", "write" } + }; + + + var codeVerifier = OAuthService.GenerateCodeVerifier(); + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.NotNull(authUrl); + Assert.Contains("app.contentstack.com", authUrl); + Assert.Contains("response_type=code", authUrl); + Assert.Contains("client_id=test_client_id", authUrl); + Assert.Contains("redirect_uri=http%3A%2F%2Flocalhost%3A8080", authUrl); + Assert.Contains("code_challenge=", authUrl); + Assert.Contains("code_challenge_method=S256", authUrl); + Assert.Contains("scope=read%20write", authUrl); + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandlePKCEFlow() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id" + // No client secret - PKCE flow + }; + + + var codeVerifier = OAuthService.GenerateCodeVerifier(); + var codeChallenge = OAuthService.GenerateCodeChallenge(codeVerifier); + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.NotNull(codeVerifier); + Assert.NotNull(codeChallenge); + Assert.NotNull(authUrl); + Assert.Contains("code_challenge=", authUrl); + Assert.Contains("code_challenge_method=S256", authUrl); + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandleTraditionalOAuthFlow() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthClientSecret = "test_client_secret", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id" + }; + + + var codeVerifier = OAuthService.GenerateCodeVerifier(); + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.NotNull(authUrl); + Assert.Contains("code_challenge=", authUrl); + Assert.Contains("code_challenge_method=S256", authUrl); + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandleTokenExpiration() + { + + var options = new ContentstackOptions + { + AccessToken = "test_access_token", + RefreshToken = "test_refresh_token", + TokenExpiresAt = DateTime.UtcNow.AddMinutes(-1) // Expired + }; + + + var isExpired = OAuthService.IsTokenExpired(options); + + + Assert.True(isExpired); + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandleValidToken() + { + + var options = new ContentstackOptions + { + AccessToken = "test_access_token", + RefreshToken = "test_refresh_token", + TokenExpiresAt = DateTime.UtcNow.AddMinutes(30) // Valid + }; + + + var isExpired = OAuthService.IsTokenExpired(options); + + + Assert.False(isExpired); + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandleLogout() + { + + var options = new ContentstackOptions + { + AccessToken = "test_access_token", + RefreshToken = "test_refresh_token", + TokenExpiresAt = DateTime.UtcNow.AddMinutes(30), + Authorization = "Bearer test_access_token" + }; + + + OAuthService.LogoutAsync(options); + + + Assert.Null(options.AccessToken); + Assert.Null(options.RefreshToken); + Assert.Null(options.TokenExpiresAt); + // Note: Authorization might not be cleared in current implementation + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandleScopesCorrectly() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id", + OAuthScopes = new[] { "read", "write", "admin" } + }; + + + var codeVerifier = OAuthService.GenerateCodeVerifier(); + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.Contains("scope=read%20write%20admin", authUrl); + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandleEmptyScopes() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id", + OAuthScopes = new string[0] // Empty array + }; + + + var codeVerifier = OAuthService.GenerateCodeVerifier(); + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.DoesNotContain("scope=", authUrl); + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandleNullScopes() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id", + OAuthScopes = null + }; + + + var codeVerifier = OAuthService.GenerateCodeVerifier(); + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.DoesNotContain("scope=", authUrl); + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandleHostnameTransformation() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id" + }; + + + var codeVerifier = OAuthService.GenerateCodeVerifier(); + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.Contains("app.contentstack.com", authUrl); + Assert.DoesNotContain("api.contentstack.io", authUrl); + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandleSpecialCharactersInScopes() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id", + OAuthScopes = new[] { "read content", "write content", "manage users" } + }; + + + var codeVerifier = OAuthService.GenerateCodeVerifier(); + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.Contains("scope=read%20content%20write%20content%20manage%20users", authUrl); + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandleLongScopes() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id", + OAuthScopes = new[] { + "read", "write", "admin", "manage", "create", "delete", + "update", "view", "edit", "publish", "unpublish", "archive" + } + }; + + + var codeVerifier = OAuthService.GenerateCodeVerifier(); + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.NotNull(authUrl); + Assert.Contains("scope=", authUrl); + // Should contain all scopes URL encoded + Assert.Contains("read", authUrl); + Assert.Contains("write", authUrl); + Assert.Contains("admin", authUrl); + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandleEdgeCaseTokenExpiration() + { + + var options = new ContentstackOptions + { + AccessToken = "test_access_token", + RefreshToken = "test_refresh_token", + TokenExpiresAt = DateTime.UtcNow.AddSeconds(30) // Near expiration (30 seconds) + }; + + + var isExpired = OAuthService.IsTokenExpired(options); + + + Assert.True(isExpired); // Should be considered expired due to buffer time + } + + [Fact] + public void CompleteOAuthFlow_ShouldHandleMultipleLogoutCalls() + { + + var options = new ContentstackOptions + { + AccessToken = "test_access_token", + RefreshToken = "test_refresh_token", + TokenExpiresAt = DateTime.UtcNow.AddMinutes(30) + }; + + + OAuthService.LogoutAsync(options); + OAuthService.LogoutAsync(options); // Second call + + + Assert.Null(options.AccessToken); + Assert.Null(options.RefreshToken); + Assert.Null(options.TokenExpiresAt); + } + } +} + + diff --git a/contentstack.model.generator.tests/OAuthServiceTests.cs b/contentstack.model.generator.tests/OAuthServiceTests.cs new file mode 100644 index 0000000..356370e --- /dev/null +++ b/contentstack.model.generator.tests/OAuthServiceTests.cs @@ -0,0 +1,290 @@ +using System; +using System.Threading.Tasks; +using Xunit; +using Moq; +using contentstack.CMA; +using contentstack.CMA.OAuth; +using System.Net.Http; +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace contentstack.model.generator.tests +{ + public class OAuthServiceTests + { + [Fact] + public void GenerateCodeVerifier_ShouldReturnValidBase64UrlEncodedString() + { + + var codeVerifier = OAuthService.GenerateCodeVerifier(); + + + Assert.NotNull(codeVerifier); + Assert.NotEmpty(codeVerifier); + Assert.True(codeVerifier.Length >= 43); // Minimum length for PKCE + Assert.True(codeVerifier.Length <= 128); // Maximum length for PKCE + + // Should be Base64URL encoded (no padding, no + or / characters) + Assert.DoesNotContain("+", codeVerifier); + Assert.DoesNotContain("/", codeVerifier); + Assert.DoesNotContain("=", codeVerifier); + } + + [Fact] + public void GenerateCodeVerifier_ShouldReturnDifferentValues() + { + + var codeVerifier1 = OAuthService.GenerateCodeVerifier(); + var codeVerifier2 = OAuthService.GenerateCodeVerifier(); + + + Assert.NotEqual(codeVerifier1, codeVerifier2); + } + + [Theory] + [InlineData("test_code_verifier_123")] + [InlineData("another_test_verifier")] + [InlineData("a")] + public void GenerateCodeChallenge_ShouldReturnValidBase64UrlEncodedString(string codeVerifier) + { + + var codeChallenge = OAuthService.GenerateCodeChallenge(codeVerifier); + + + Assert.NotNull(codeChallenge); + Assert.NotEmpty(codeChallenge); + + // Should be Base64URL encoded + Assert.DoesNotContain("+", codeChallenge); + Assert.DoesNotContain("/", codeChallenge); + Assert.DoesNotContain("=", codeChallenge); + } + + [Fact] + public void GenerateCodeChallenge_ShouldReturnConsistentResults() + { + + var codeVerifier = "test_code_verifier_123"; + + + var challenge1 = OAuthService.GenerateCodeChallenge(codeVerifier); + var challenge2 = OAuthService.GenerateCodeChallenge(codeVerifier); + + + Assert.Equal(challenge1, challenge2); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldIncludeRequiredParameters() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id" + }; + var codeVerifier = "test_code_verifier"; + + + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.NotNull(authUrl); + Assert.Contains("app.contentstack.com", authUrl); + Assert.Contains("response_type=code", authUrl); + Assert.Contains("client_id=test_client_id", authUrl); + Assert.Contains("redirect_uri=http%3A%2F%2Flocalhost%3A8080", authUrl); + Assert.Contains("code_challenge=", authUrl); + Assert.Contains("code_challenge_method=S256", authUrl); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldIncludeScopesWhenProvided() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id", + OAuthScopes = new[] { "read", "write" } + }; + var codeVerifier = "test_code_verifier"; + + + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.Contains("scope=read%20write", authUrl); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldNotIncludeScopesWhenNotProvided() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id", + OAuthScopes = null + }; + var codeVerifier = "test_code_verifier"; + + + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.DoesNotContain("scope=", authUrl); + } + + [Fact] + public void GenerateAuthorizationUrl_ShouldNotIncludeScopesWhenEmptyArray() + { + + var options = new ContentstackOptions + { + Host = "api.contentstack.io", + OAuthClientId = "test_client_id", + OAuthRedirectUri = "http://localhost:8080", + OAuthAppId = "test_app_id", + OAuthScopes = new string[0] + }; + var codeVerifier = "test_code_verifier"; + + + var authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + + Assert.DoesNotContain("scope=", authUrl); + } + + [Theory] + [InlineData("api.contentstack.io", "app.contentstack.com")] + [InlineData("https://api.contentstack.io", "app.contentstack.com")] + [InlineData("http://api.contentstack.io", "app.contentstack.com")] + public void GetOAuthHost_ShouldTransformHostnameCorrectly(string inputHost, string expectedHost) + { + + var result = OAuthService.GetOAuthHost(inputHost); + + + Assert.Equal(expectedHost, result); + } + + [Theory] + [InlineData("api.contentstack.io", "https://developerhub-api.contentstack.com")] + [InlineData("https://api.contentstack.io", "https://developerhub-api.contentstack.com")] + [InlineData("http://api.contentstack.io", "https://developerhub-api.contentstack.com")] + public void GetDeveloperHubHostname_ShouldTransformHostnameCorrectly(string inputHost, string expectedHost) + { + + var result = OAuthService.GetDeveloperHubHostname(inputHost); + + + Assert.Equal(expectedHost, result); + } + + [Fact] + public void IsTokenExpired_ShouldReturnTrueForExpiredToken() + { + + var options = new ContentstackOptions + { + TokenExpiresAt = DateTime.UtcNow.AddMinutes(-1) // Expired 1 minute ago + }; + + + var isExpired = OAuthService.IsTokenExpired(options); + + + Assert.True(isExpired); + } + + [Fact] + public void IsTokenExpired_ShouldReturnFalseForValidToken() + { + + var options = new ContentstackOptions + { + TokenExpiresAt = DateTime.UtcNow.AddMinutes(30) // Valid for 30 more minutes + }; + + + var isExpired = OAuthService.IsTokenExpired(options); + + + Assert.False(isExpired); + } + + [Fact] + public void IsTokenExpired_ShouldReturnTrueForNullExpiration() + { + + var options = new ContentstackOptions + { + TokenExpiresAt = null + }; + + + var isExpired = OAuthService.IsTokenExpired(options); + + + Assert.True(isExpired); + } + + [Fact] + public void IsTokenExpired_ShouldReturnTrueForNearExpiration() + { + + var options = new ContentstackOptions + { + TokenExpiresAt = DateTime.UtcNow.AddSeconds(30) // Expires in 30 seconds (near expiration) + }; + + + var isExpired = OAuthService.IsTokenExpired(options); + + + Assert.True(isExpired); + } + + [Fact] + public void LogoutAsync_ShouldClearTokens() + { + + var options = new ContentstackOptions + { + AccessToken = "test_access_token", + RefreshToken = "test_refresh_token", + TokenExpiresAt = DateTime.UtcNow.AddMinutes(30) + }; + + + OAuthService.LogoutAsync(options); + + + Assert.Null(options.AccessToken); + Assert.Null(options.RefreshToken); + Assert.Null(options.TokenExpiresAt); + } + + [Fact] + public async Task LogoutAsync_ShouldNotThrowWhenTokensAreNull() + { + + var options = new ContentstackOptions + { + AccessToken = null, + RefreshToken = null, + TokenExpiresAt = null + }; + var exception = await Record.ExceptionAsync(() => OAuthService.LogoutAsync(options)); + Assert.Null(exception); + } + } +} diff --git a/contentstack.model.generator.tests/OAuthTokenExchangeTests.cs b/contentstack.model.generator.tests/OAuthTokenExchangeTests.cs new file mode 100644 index 0000000..d16a597 --- /dev/null +++ b/contentstack.model.generator.tests/OAuthTokenExchangeTests.cs @@ -0,0 +1,82 @@ +using System; +using contentstack.CMA.OAuth; +using Xunit; + +namespace contentstack.model.generator.tests +{ + public class OAuthTokenExchangeTests + { + #region OAuthTokenResponse Tests + + [Fact] + public void OAuthTokenResponse_ShouldHaveCorrectProperties() + { + + var response = new OAuthTokenResponse + { + AccessToken = "test_access_token", + RefreshToken = "test_refresh_token", + TokenType = "Bearer", + ExpiresIn = 3600, + OrganizationUid = "test_org_uid", + UserUid = "test_user_uid" + }; + + + Assert.Equal("test_access_token", response.AccessToken); + Assert.Equal("test_refresh_token", response.RefreshToken); + Assert.Equal("Bearer", response.TokenType); + Assert.Equal(3600, response.ExpiresIn); + Assert.Equal("test_org_uid", response.OrganizationUid); + Assert.Equal("test_user_uid", response.UserUid); + } + + [Fact] + public void OAuthTokenResponse_ShouldHandleNullValues() + { + + var response = new OAuthTokenResponse(); + + + Assert.Null(response.AccessToken); + Assert.Null(response.RefreshToken); + Assert.Null(response.TokenType); + Assert.Equal(0, response.ExpiresIn); + Assert.Null(response.OrganizationUid); + Assert.Null(response.UserUid); + } + + [Fact] + public void OAuthTokenResponse_ShouldBeSerializable() + { + + var response = new OAuthTokenResponse + { + AccessToken = "test_access_token", + RefreshToken = "test_refresh_token", + TokenType = "Bearer", + ExpiresIn = 3600, + OrganizationUid = "test_org_uid", + UserUid = "test_user_uid" + }; + + + var json = Newtonsoft.Json.JsonConvert.SerializeObject(response); + var deserializedResponse = Newtonsoft.Json.JsonConvert.DeserializeObject(json); + + + Assert.NotNull(deserializedResponse); + Assert.Equal(response.AccessToken, deserializedResponse.AccessToken); + Assert.Equal(response.RefreshToken, deserializedResponse.RefreshToken); + Assert.Equal(response.TokenType, deserializedResponse.TokenType); + Assert.Equal(response.ExpiresIn, deserializedResponse.ExpiresIn); + Assert.Equal(response.OrganizationUid, deserializedResponse.OrganizationUid); + Assert.Equal(response.UserUid, deserializedResponse.UserUid); + } + + #endregion + + #region OAuth Flow Integration Notes + #endregion + } +} \ No newline at end of file diff --git a/contentstack.model.generator.tests/contentstack.model.generator.tests.csproj b/contentstack.model.generator.tests/contentstack.model.generator.tests.csproj new file mode 100644 index 0000000..76635fa --- /dev/null +++ b/contentstack.model.generator.tests/contentstack.model.generator.tests.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/contentstack.model.generator/CMA/ContentstackClient.cs b/contentstack.model.generator/CMA/ContentstackClient.cs index 608ff2c..96d3041 100644 --- a/contentstack.model.generator/CMA/ContentstackClient.cs +++ b/contentstack.model.generator/CMA/ContentstackClient.cs @@ -68,11 +68,23 @@ public void SetHeader(string key, string value) } public ContentstackClient(ContentstackOptions options) { + if (options == null) + throw new ArgumentNullException(nameof(options)); + ContentstackOptions _options = options; this.StackApiKey = _options.ApiKey; this._LocalHeaders = new Dictionary(); this.SetHeader("api_key", _options.ApiKey); - this.SetHeader("authtoken", _options.Authtoken); + + if (_options.IsOAuth && !string.IsNullOrEmpty(_options.Authorization)) + { + this.SetHeader("Authorization", _options.Authorization); + } + else + { + this.SetHeader("authtoken", _options.Authtoken); + } + Config cnfig = new Config(); if (_options.Host != null) { @@ -296,6 +308,7 @@ private Dictionary GetHeader(Dictionary localHea Dictionary mainHeader = _StackHeaders; Dictionary classHeaders = new Dictionary(); + if (localHeader != null && localHeader.Count > 0) { if (mainHeader != null && mainHeader.Count > 0) diff --git a/contentstack.model.generator/CMA/ContentstackOptions.cs b/contentstack.model.generator/CMA/ContentstackOptions.cs index f423d9b..3613b35 100644 --- a/contentstack.model.generator/CMA/ContentstackOptions.cs +++ b/contentstack.model.generator/CMA/ContentstackOptions.cs @@ -34,6 +34,20 @@ public class ContentstackOptions /// The Version number for the ContentStack API. /// public string Version { get; set; } + + + public bool IsOAuth { get; set; } = false; + public string Authorization { get; set; } + + // OAuth specific properties + public string OAuthClientId { get; set; } + public string OAuthClientSecret { get; set; } + public string OAuthRedirectUri { get; set; } + public string OAuthAppId { get; set; } + public string[] OAuthScopes { get; set; } + public string AccessToken { get; set; } + public string RefreshToken { get; set; } + public DateTime? TokenExpiresAt { get; set; } } } diff --git a/contentstack.model.generator/CMA/HTTPRequestHandler.cs b/contentstack.model.generator/CMA/HTTPRequestHandler.cs index 2ad1db9..9214d28 100644 --- a/contentstack.model.generator/CMA/HTTPRequestHandler.cs +++ b/contentstack.model.generator/CMA/HTTPRequestHandler.cs @@ -1,85 +1,114 @@ -using Newtonsoft.Json; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Text; -using System.Threading.Tasks; - -namespace contentstack.CMA -{ - internal class HttpRequestHandler - { - public async Task ProcessRequest(string Url, Dictionary Headers, Dictionary BodyJson, string FileName = null) { - - String queryParam = String.Join("&", BodyJson.Select(kvp => { - var value = ""; - if (kvp.Value is string[]) - { - string[] vals = (string[])kvp.Value; - value = String.Join("&", vals.Select(item => - { - return String.Format("{0}={1}", kvp.Key, item); - })); - return value; - } - else if (kvp.Value is Dictionary) - value = JsonConvert.SerializeObject(kvp.Value); - else - return String.Format("{0}={1}", kvp.Key, kvp.Value); - - return String.Format("{0}={1}", kvp.Key, value); - - })); - - var uri = new Uri(Url+"?"+queryParam); - - var request = (HttpWebRequest)WebRequest.Create(uri); - request.Method = "GET"; - request.ContentType = "application/json"; - request.Headers["x-user-agent"]= "contentstack-model-generator/0.4.2"; - - if (Headers != default(IDictionary)) { - foreach (var header in Headers) { - try { - request.Headers[header.Key] = header.Value.ToString(); - } catch (Exception e) - { - Console.WriteLine(e); - throw; - } - } - } - - StreamReader reader = null; - HttpWebResponse response = null; - - try { - - response = (HttpWebResponse)await request.GetResponseAsync(); - if (response != null) { - reader = new StreamReader(response.GetResponseStream()); - - string responseString = await reader.ReadToEndAsync(); - - return responseString; - } else { - return null; - } - } catch (Exception e) - { - throw; - } finally { - if (reader != null) { - reader.Dispose(); - } - if (response != null) - { - response.Dispose(); - } - } - - } - } -} +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using contentstack.CMA.Http; + +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) + { + // Build query parameters + String queryParam = String.Join("&", BodyJson.Select(kvp => { + var value = ""; + if (kvp.Value is string[]) + { + string[] vals = (string[])kvp.Value; + value = String.Join("&", vals.Select(item => + { + return String.Format("{0}={1}", kvp.Key, item); + })); + return value; + } + else if (kvp.Value is Dictionary) + value = JsonConvert.SerializeObject(kvp.Value); + else + return String.Format("{0}={1}", kvp.Key, kvp.Value); + + return String.Format("{0}={1}", kvp.Key, value); + })); + + var uri = new Uri(Url + "?" + queryParam); + + // Create HTTP request + var request = new HttpRequestMessage(HttpMethod.Get, uri); + request.Headers.Add("x-user-agent", "contentstack-model-generator/0.4.2"); + + if (Headers != null) + { + foreach (var header in Headers) + { + try + { + // Handle OAuth Bearer token + if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) + { + // Check if the value already contains "Bearer" prefix + string authValue = header.Value.ToString(); + if (authValue.StartsWith("Bearer ")) + { + string token = authValue.Substring(7); // Remove "Bearer " prefix + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); + } + else + { + // Value doesn't have Bearer prefix, add it + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", authValue); + } + } + else + { + request.Headers.Add(header.Key, header.Value.ToString()); + } + } + catch (Exception e) + { + Console.WriteLine(e); + throw; + } + } + } + + try + { + var response = await _httpClient.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + var responseContent = await response.Content.ReadAsStringAsync(); + return responseContent; + } + else + { + var errorContent = await response.Content.ReadAsStringAsync(); + throw new HttpRequestException($"HTTP request failed with status code: {response.StatusCode} - {errorContent}"); + } + } + catch (Exception ex) + { + throw; + } + } + + public void Dispose() + { + _httpClient?.Dispose(); + } + } +} diff --git a/contentstack.model.generator/CMA/Http/ContentstackHttpRequest.cs b/contentstack.model.generator/CMA/Http/ContentstackHttpRequest.cs new file mode 100644 index 0000000..16f19fe --- /dev/null +++ b/contentstack.model.generator/CMA/Http/ContentstackHttpRequest.cs @@ -0,0 +1,199 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using System.Net.Http.Headers; +using System.Collections.Generic; +using System.Net; +using System.IO; +using Newtonsoft.Json; + +namespace contentstack.CMA.Http +{ + internal class ContentstackHttpRequest : IHttpRequest + { + #region Private + private bool _disposed = false; + private readonly HttpClient _httpClient; + private readonly HttpRequestMessage _request; + private readonly JsonSerializer _serializer; + #endregion + + #region Public + /// + /// The HTTP method or verb. + /// + public HttpMethod Method + { + get { return _request.Method; } + set { _request.Method = value; } + } + + /// + /// The request URI. + /// + public Uri RequestUri { get; set; } + + /// + /// The underlying HttpClient + /// + public HttpClient HttpClient + { + get { return _httpClient; } + } + + /// + /// The underlying HTTP web request. + /// + public HttpRequestMessage Request + { + get { return _request; } + } + + #endregion + + #region Constructor + internal ContentstackHttpRequest(HttpClient httpClient, JsonSerializer serializer) + { + _httpClient = httpClient; + _serializer = serializer; + _request = new HttpRequestMessage(); + } + #endregion + + #region Dispose methods + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing) + { + _disposed = true; + if (_request != null) + { + _request.Dispose(); + } + } + } + + private void ThrowIfDisposed() + { + if (this._disposed) + throw new ObjectDisposedException(GetType().FullName); + } + + #endregion + + /// + /// Returns the HTTP response. + /// + /// + public IResponse GetResponse() + { + ThrowIfDisposed(); + try + { + return this.GetResponseAsync().Result; + } + catch (AggregateException e) + { + throw e.InnerException; + } + } + + /// + /// Returns the HTTP response. + /// + /// The . + public async Task GetResponseAsync() + { + ThrowIfDisposed(); + try + { + _request.RequestUri = this.RequestUri; + + var responseMessage = await _httpClient.SendAsync(_request, HttpCompletionOption.ResponseHeadersRead) + .ConfigureAwait(continueOnCapturedContext: false); + + if (responseMessage.StatusCode >= HttpStatusCode.Ambiguous && + responseMessage.StatusCode < HttpStatusCode.BadRequest) + return new ContentstackResponse(responseMessage, _serializer); + + if (!responseMessage.IsSuccessStatusCode) + { + throw new HttpRequestException($"HTTP request failed with status code: {responseMessage.StatusCode}"); + } + + return new ContentstackResponse(responseMessage, _serializer); + } + catch (HttpRequestException httpException) + { + if (httpException.InnerException is IOException) + { + throw httpException.InnerException; + } + throw; + } + } + + /// + /// Sets the headers on the request. + /// + /// A dictionary of header names and values. + public void SetRequestHeaders(IDictionary headers) + { + ThrowIfDisposed(); + foreach (var kvp in headers) + { + _request.Headers.TryAddWithoutValidation(kvp.Key, kvp.Value); + } + } + + /// + /// Writes a stream to the request body. + /// + /// The destination where the content stream is written. + public void WriteToRequestBody(HttpContent content) + { + _request.Content = content; + } + + /// + /// Gets a handle to the request content. + /// + /// The . + public HttpContent GetRequestContent() + { + ThrowIfDisposed(); + return System.Threading.Tasks.Task.FromResult(_request.Content).Result; + } + + /// + /// Writes a stream to the request body. + /// + /// The destination where the content stream is written. + /// A dictionary of header names and values. + public void WriteToRequestBody(HttpContent content, IDictionary contentHeaders) + { + ThrowIfDisposed(); + WriteToRequestBody(content); + WriteContentHeaders(contentHeaders); + } + + private void WriteContentHeaders(IDictionary contentHeaders) + { + if (contentHeaders.ContainsKey("Content-Type")) + { + _request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentHeaders["Content-Type"]); + } + } + } +} + diff --git a/contentstack.model.generator/CMA/Http/ContentstackResponse.cs b/contentstack.model.generator/CMA/Http/ContentstackResponse.cs new file mode 100644 index 0000000..4a4605e --- /dev/null +++ b/contentstack.model.generator/CMA/Http/ContentstackResponse.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace contentstack.CMA.Http +{ + /// + /// Abstract class for Response objects + /// + public class ContentstackResponse : IResponse, IDisposable + { + private bool _disposed = false; + + string[] _headerNames; + Dictionary _headers; + HashSet _headerNamesSet; + private readonly HttpResponseMessage _response; + private readonly JsonSerializer _serializer; + + #region Public + /// + /// Returns the content length of the HTTP response. + /// + public long ContentLength { get; private set; } + + /// + /// Gets the property ContentType. + /// + public string ContentType { get; private set; } + + /// + /// The HTTP status code from the HTTP response. + /// + public HttpStatusCode StatusCode { get; private set; } + + /// + /// Gets a value that indicates whether the HTTP response was successful. + /// + public bool IsSuccessStatusCode { get; private set; } + + /// + /// The entire response body from the HTTP response. + /// + public HttpResponseMessage ResponseBody { + get + { + return _response; + } + } + + /// + /// Gets the header names from HTTP response headers. + /// + /// The string Array + public string[] GetHeaderNames() + { + return _headerNames; + } + + /// + /// Gets the value for the header name from HTTP response headers. + /// + /// Header name for which value is needed + /// The string + public string GetHeaderValue(string headerName) + { + string headerValue; + if (_headers.TryGetValue(headerName, out headerValue)) + return headerValue; + + return string.Empty; + } + + /// + /// Return true if header name present in HTTP response headers. + /// + /// + /// The bool + public bool IsHeaderPresent(string headerName) + { + return _headerNamesSet.Contains(headerName); + } + #endregion + + #region Private + private static string GetFirstHeaderValue(HttpHeaders headers, string key) + { + IEnumerable headerValues = null; + if (headers.TryGetValues(key, out headerValues)) + return headerValues.FirstOrDefault(); + + return string.Empty; + } + + private void CopyHeaderValues(HttpResponseMessage response) + { + List headerNames = new List(); + _headers = new Dictionary(10, StringComparer.OrdinalIgnoreCase); + + foreach (var header in response.Headers) + { + headerNames.Add(header.Key); + var headerValue = GetFirstHeaderValue(response.Headers, header.Key); + _headers.Add(header.Key, headerValue); + } + + if (response.Content != null) + { + foreach (var header in response.Content.Headers) + { + if (!headerNames.Contains(header.Key)) + { + headerNames.Add(header.Key); + var headerValue = GetFirstHeaderValue(response.Content.Headers, header.Key); + _headers.Add(header.Key, headerValue); + } + } + } + _headerNames = headerNames.ToArray(); + _headerNamesSet = new HashSet(_headerNames, StringComparer.OrdinalIgnoreCase); + } + #endregion + + internal ContentstackResponse(HttpResponseMessage response, JsonSerializer serializer) + { + _response = response; + _serializer = serializer; + + this.StatusCode = response.StatusCode; + this.IsSuccessStatusCode = response.IsSuccessStatusCode; + this.ContentLength = response.Content.Headers.ContentLength ?? 0; + + if (response.Content.Headers.ContentType != null) + { + this.ContentType = response.Content.Headers.ContentType.MediaType; + } + CopyHeaderValues(response); + + } + + /// + /// Json Object format response. + /// + /// The JObject. + public JObject OpenJObjectResponse() + { + ThrowIfDisposed(); + return JObject.Parse(OpenResponse()); + } + + /// + /// String format response. + /// + /// The string. + public string OpenResponse() + { + ThrowIfDisposed(); + return _response.Content.ReadAsStringAsync().Result; + } + + /// + /// Type response to serialize the response. + /// + /// The type to serialize the response into. + /// + public TResponse OpenTResponse() + { + ThrowIfDisposed(); + JObject jObject = OpenJObjectResponse(); + return jObject.ToObject(_serializer); + } + + + #region Dispose method + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + + _disposed = true; + + } + + private void ThrowIfDisposed() + { + if (this._disposed) + throw new ObjectDisposedException(GetType().FullName); + } + #endregion + } +} diff --git a/contentstack.model.generator/CMA/Http/IHttpRequest.cs b/contentstack.model.generator/CMA/Http/IHttpRequest.cs new file mode 100644 index 0000000..1b3b335 --- /dev/null +++ b/contentstack.model.generator/CMA/Http/IHttpRequest.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; + +namespace contentstack.CMA.Http +{ + public interface IHttpRequest: IDisposable + { + /// + /// The HTTP method or verb. + /// + HttpMethod Method { get; set; } + + /// + /// The request URI. + /// + Uri RequestUri { get; } + + /// + /// Sets the headers on the request. + /// + /// A dictionary of header names and values. + void SetRequestHeaders(IDictionary headers); + + /// + /// Returns the HTTP response. + /// + /// + HttpContent GetRequestContent(); + + /// + /// Returns the HTTP response. + /// + /// + IResponse GetResponse(); + + /// + /// Returns the HTTP response. + /// + /// + System.Threading.Tasks.Task GetResponseAsync(); + + + /// + /// Writes a byte array to the request body. + /// + /// The content stream to be written. + /// HTTP content headers. + void WriteToRequestBody(HttpContent content, IDictionary contentHeaders); + } +} + diff --git a/contentstack.model.generator/CMA/Http/IResponse.cs b/contentstack.model.generator/CMA/Http/IResponse.cs new file mode 100644 index 0000000..87b94d0 --- /dev/null +++ b/contentstack.model.generator/CMA/Http/IResponse.cs @@ -0,0 +1,27 @@ +using System; +using System.Net; +using Newtonsoft.Json.Linq; + +namespace contentstack.CMA.Http +{ + /// + /// Interface for a response. + /// + public interface IResponse + { + long ContentLength { get; } + string ContentType { get; } + HttpStatusCode StatusCode { get; } + bool IsSuccessStatusCode { get; } + string[] GetHeaderNames(); + bool IsHeaderPresent(string headerName); + string GetHeaderValue(string headerName); + + string OpenResponse(); + + JObject OpenJObjectResponse(); + + TResponse OpenTResponse(); + } +} + diff --git a/contentstack.model.generator/CMA/OAuth/OAuthService.cs b/contentstack.model.generator/CMA/OAuth/OAuthService.cs new file mode 100644 index 0000000..fa8c085 --- /dev/null +++ b/contentstack.model.generator/CMA/OAuth/OAuthService.cs @@ -0,0 +1,435 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace contentstack.CMA.OAuth +{ + /// + /// OAuth service for handling OAuth 2.0 authentication flow with PKCE + /// + public class OAuthService + { + protected OAuthService() { } + + /// + /// Generates a PKCE code verifier + /// + /// Base64URL encoded code verifier + public static string GenerateCodeVerifier() + { + // Generate 32 random bytes (256 bits) + byte[] randomBytes = new byte[32]; + using (var rng = RandomNumberGenerator.Create()) + { + rng.GetBytes(randomBytes); + } + + // Convert to Base64URL encoding + return Base64UrlEncode(randomBytes); + } + + /// + /// Generates a PKCE code challenge from the code verifier + /// + /// The code verifier + /// Base64URL encoded code challenge + public static string GenerateCodeChallenge(string codeVerifier) + { + if (codeVerifier == null) + throw new ArgumentNullException(nameof(codeVerifier)); + if (string.IsNullOrWhiteSpace(codeVerifier)) + throw new ArgumentException("Code verifier cannot be empty or whitespace", nameof(codeVerifier)); + + // Create SHA256 hash of the code verifier + using (var sha256 = SHA256.Create()) + { + byte[] hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(codeVerifier)); + + // Convert to Base64URL encoding + return Base64UrlEncode(hashBytes); + } + } + + /// + /// Generates OAuth authorization URL + /// + /// OAuth configuration options + /// PKCE code verifier + /// Authorization URL + public static string GenerateAuthorizationUrl(ContentstackOptions options, string codeVerifier) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + if (codeVerifier == null) + throw new ArgumentNullException(nameof(codeVerifier)); + if (string.IsNullOrEmpty(codeVerifier)) + throw new ArgumentException("Code verifier cannot be empty", nameof(codeVerifier)); + if (options.OAuthClientId == null) + throw new ArgumentNullException(nameof(options.OAuthClientId)); + if (string.IsNullOrEmpty(options.OAuthClientId)) + throw new ArgumentException("OAuth Client ID cannot be empty", nameof(options.OAuthClientId)); + if (options.OAuthRedirectUri == null) + throw new ArgumentNullException(nameof(options.OAuthRedirectUri)); + if (string.IsNullOrEmpty(options.OAuthRedirectUri)) + throw new ArgumentException("OAuth Redirect URI cannot be empty", nameof(options.OAuthRedirectUri)); + if (options.OAuthAppId == null) + throw new ArgumentNullException(nameof(options.OAuthAppId)); + if (string.IsNullOrEmpty(options.OAuthAppId)) + throw new ArgumentException("OAuth App ID cannot be empty", nameof(options.OAuthAppId)); + if (options.Host == null) + throw new ArgumentNullException(nameof(options.Host)); + if (string.IsNullOrEmpty(options.Host)) + throw new ArgumentException("Host cannot be empty", nameof(options.Host)); + + // Generate code challenge from verifier + string codeChallenge = GenerateCodeChallenge(codeVerifier); + + // Transform hostname to OAuth host (api.contentstack.io -> app.contentstack.com) + string oauthHost = GetOAuthHost(options.Host); + + // Build authorization URL using the correct format from management SDK + var queryParams = new List + { + $"response_type=code", + $"client_id={Uri.EscapeDataString(options.OAuthClientId)}", + $"redirect_uri={Uri.EscapeDataString(options.OAuthRedirectUri)}", + $"code_challenge={Uri.EscapeDataString(codeChallenge)}", + $"code_challenge_method=S256" + }; + + // Add scopes only if provided (optional, by default empty) + if (options.OAuthScopes != null && options.OAuthScopes.Length > 0) + { + var scopeString = string.Join(" ", options.OAuthScopes); + queryParams.Add($"scope={Uri.EscapeDataString(scopeString)}"); + } + + // Construct the full URL using the correct OAuth host format + string baseUrl = $"https://{oauthHost}/#!/apps/{options.OAuthAppId}/authorize"; + return $"{baseUrl}?{string.Join("&", queryParams)}"; + } + + /// + /// Exchanges authorization code for access token + /// + /// OAuth configuration options + /// Authorization code from callback + /// PKCE code verifier + /// OAuth token response + public static async Task ExchangeCodeForTokenAsync(ContentstackOptions options, string authorizationCode, string codeVerifier) + { + using (var httpClient = new HttpClient()) + { + // Prepare token request + var tokenRequest = new List> + { + new KeyValuePair("grant_type", "authorization_code"), + new KeyValuePair("client_id", options.OAuthClientId), + new KeyValuePair("code", authorizationCode), + new KeyValuePair("redirect_uri", options.OAuthRedirectUri) + }; + + // Add either client_secret (traditional OAuth) or code_verifier (PKCE) - mutually exclusive + if (!string.IsNullOrEmpty(options.OAuthClientSecret)) + { + // Traditional OAuth flow - use client secret + tokenRequest.Add(new KeyValuePair("client_secret", options.OAuthClientSecret)); + } + else if (!string.IsNullOrEmpty(codeVerifier)) + { + // PKCE flow - use code verifier + tokenRequest.Add(new KeyValuePair("code_verifier", codeVerifier)); + } + else + { + throw new ArgumentException("Either client_secret or code_verifier must be provided."); + } + + // Add app_id if provided + if (!string.IsNullOrEmpty(options.OAuthAppId)) + { + tokenRequest.Add(new KeyValuePair("app_id", options.OAuthAppId)); + } + + var formContent = new FormUrlEncodedContent(tokenRequest); + + // Make token request using Developer Hub hostname (matching management SDK) + string tokenUrl = $"{GetDeveloperHubHostname(options.Host)}/token"; + + var response = await httpClient.PostAsync(tokenUrl, formContent); + + if (!response.IsSuccessStatusCode) + { + string errorContent = await response.Content.ReadAsStringAsync(); + throw new InvalidOperationException($"Token exchange failed: {response.StatusCode} - {errorContent}"); + } + + // Parse response + string responseContent = await response.Content.ReadAsStringAsync(); + var tokenResponse = JsonConvert.DeserializeObject(responseContent); + + // Set expiration time if not already set + if (tokenResponse.ExpiresAt == default(DateTime) && tokenResponse.ExpiresIn > 0) + { + tokenResponse.ExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn); + } + + return tokenResponse; + } + } + + /// + /// Refreshes access token using refresh token + /// + /// OAuth configuration options + /// OAuth token response + public static async Task RefreshTokenAsync(ContentstackOptions options) + { + if (string.IsNullOrEmpty(options.RefreshToken)) + { + throw new ArgumentException("Refresh token is required for token refresh"); + } + + using (var httpClient = new HttpClient()) + { + // Prepare refresh request + var refreshRequest = new List> + { + new KeyValuePair("grant_type", "refresh_token"), + new KeyValuePair("client_id", options.OAuthClientId), + new KeyValuePair("refresh_token", options.RefreshToken) + }; + + // Add client secret only if provided (optional for public clients) + if (!string.IsNullOrEmpty(options.OAuthClientSecret)) + { + refreshRequest.Add(new KeyValuePair("client_secret", options.OAuthClientSecret)); + } + + // Add app_id if provided + if (!string.IsNullOrEmpty(options.OAuthAppId)) + { + refreshRequest.Add(new KeyValuePair("app_id", options.OAuthAppId)); + } + + var formContent = new FormUrlEncodedContent(refreshRequest); + + // Make refresh request using Developer Hub hostname (matching management SDK) + string tokenUrl = $"{GetDeveloperHubHostname(options.Host)}/token"; + var response = await httpClient.PostAsync(tokenUrl, formContent); + + if (!response.IsSuccessStatusCode) + { + string errorContent = await response.Content.ReadAsStringAsync(); + throw new InvalidOperationException($"Token refresh failed: {response.StatusCode} - {errorContent}"); + } + + // Parse response + string responseContent = await response.Content.ReadAsStringAsync(); + var tokenResponse = JsonConvert.DeserializeObject(responseContent); + + // Set expiration time if not already set + if (tokenResponse.ExpiresAt == default(DateTime) && tokenResponse.ExpiresIn > 0) + { + tokenResponse.ExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn); + } + + return tokenResponse; + } + } + + /// + /// Checks if the current access token is expired or near expiration + /// + /// OAuth configuration options + /// True if token is expired or near expiration + public static bool IsTokenExpired(ContentstackOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + if (!options.TokenExpiresAt.HasValue) + return true; + + // Check if token expires within the next 5 minutes (buffer time) + return options.TokenExpiresAt.Value <= DateTime.UtcNow.AddMinutes(5); + } + + /// + /// Logs out the user by clearing OAuth tokens and optionally revoking authorization + /// + /// OAuth configuration options + /// Logout success message + public static async Task LogoutAsync(ContentstackOptions options) + { + if (options == null) + throw new ArgumentNullException(nameof(options)); + + try + { + // Try to revoke the OAuth app authorization if we have valid tokens + if (!string.IsNullOrEmpty(options.AccessToken)) + { + try + { + await RevokeOAuthAuthorizationAsync(); + } + catch + { + // If revocation fails, continue with logout + // This is common in OAuth implementations where revocation is optional + } + } + + // Clear OAuth tokens from options + options.AccessToken = null; + options.RefreshToken = null; + options.TokenExpiresAt = null; + options.Authorization = null; + + return "Logged out successfully"; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Failed to logout: {ex.Message}", ex); + } + } + + /// + /// Revokes OAuth app authorization + /// + private static async Task RevokeOAuthAuthorizationAsync() + { + // This is a simplified revocation - in a full implementation, + // you would need to call the OAuth app revocation API + // For now, we'll just clear the tokens as the main logout functionality + Console.WriteLine("OAuth authorization revoked"); + await Task.CompletedTask; // Make it async + } + + /// + /// Converts bytes to Base64URL encoding (RFC 4648) + /// + /// Input bytes + /// Base64URL encoded string + private static string Base64UrlEncode(byte[] input) + { + // Convert to base64 + string base64 = Convert.ToBase64String(input); + + // Replace characters for URL safety + return base64.Replace('+', '-') + .Replace('/', '_') + .TrimEnd('='); + } + + /// + /// Transforms the base hostname to the OAuth authorization hostname. + /// + /// The base hostname (e.g., api.contentstack.io) + /// The transformed OAuth hostname (e.g., app.contentstack.com) + internal static string GetOAuthHost(string baseHost) + { + if (baseHost == null) + throw new ArgumentNullException(nameof(baseHost)); + if (string.IsNullOrEmpty(baseHost)) + throw new ArgumentException("Base host cannot be empty", nameof(baseHost)); + + // Extract hostname from URL if it contains protocol + var oauthHost = baseHost; + if (oauthHost.StartsWith("https://")) + { + oauthHost = oauthHost.Substring(8); // Remove "https://" + } + else if (oauthHost.StartsWith("http://")) + { + oauthHost = oauthHost.Substring(7); // Remove "http://" + } + + // Transform api.contentstack.io -> app.contentstack.com + // Replace .io with .com + if (oauthHost.EndsWith(".io")) + { + oauthHost = oauthHost.Replace(".io", ".com"); + } + + // Replace 'api' with 'app' + if (oauthHost.Contains("api.")) + { + oauthHost = oauthHost.Replace("api.", "app."); + } + + return oauthHost; + } + + /// + /// Transforms the base hostname to the Developer Hub API hostname. + /// + /// The base hostname (e.g., api.contentstack.io) + /// The transformed Developer Hub hostname (e.g., developerhub-api.contentstack.com) + internal static string GetDeveloperHubHostname(string baseHost) + { + if (baseHost == null) + throw new ArgumentNullException(nameof(baseHost)); + if (string.IsNullOrEmpty(baseHost)) + throw new ArgumentException("Base host cannot be empty", nameof(baseHost)); + + // Transform api.contentstack.io -> developerhub-api.contentstack.com + var devHubHost = baseHost; + + // Replace 'api' with 'developerhub-api' + if (devHubHost.Contains("api.")) + { + devHubHost = devHubHost.Replace("api.", "developerhub-api."); + } + + // Replace .io with .com + if (devHubHost.EndsWith(".io")) + { + devHubHost = devHubHost.Replace(".io", ".com"); + } + + // Always use https:// protocol for Developer Hub API + if (devHubHost.StartsWith("http://")) + { + devHubHost = devHubHost.Replace("http://", "https://"); + } + else if (!devHubHost.StartsWith("https://")) + { + devHubHost = "https://" + devHubHost; + } + + return devHubHost; + } + } + + public class OAuthTokenResponse + { + [JsonProperty("access_token")] + public string AccessToken { get; set; } + + [JsonProperty("refresh_token")] + public string RefreshToken { get; set; } + + [JsonProperty("token_type")] + public string TokenType { get; set; } + + [JsonProperty("expires_in")] + public int ExpiresIn { get; set; } + + [JsonProperty("scope")] + public string Scope { get; set; } + + [JsonProperty("organization_uid")] + public string OrganizationUid { get; set; } + + [JsonProperty("user_uid")] + public string UserUid { get; set; } + + // Computed property for expiration time + public DateTime ExpiresAt { get; set; } + } +} diff --git a/contentstack.model.generator/ModelGenerator.cs b/contentstack.model.generator/ModelGenerator.cs index 35e0412..641a530 100644 --- a/contentstack.model.generator/ModelGenerator.cs +++ b/contentstack.model.generator/ModelGenerator.cs @@ -8,6 +8,7 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using contentstack.CMA; +using contentstack.CMA.OAuth; using contentstack.model.generator.Model; using Contentstack.Model.Generator.Model; using McMaster.Extensions.CommandLineUtils; @@ -24,9 +25,25 @@ public class ModelGenerator public string ApiKey { get; set; } [Option(CommandOptionType.SingleValue, ShortName = "A", LongName = "authtoken", Description = "The Authtoken for the Content Management API")] - [Required(ErrorMessage = "You must specify the Contentstack authtoken for the Content Management API")] public string Authtoken { get; set; } + [Option(CommandOptionType.NoValue, LongName = "oauth", Description = "Use OAuth authentication instead of traditional authtoken")] + public bool UseOAuth { get; set; } + + [Option(CommandOptionType.SingleValue, ShortName = "", LongName = "app-id", Description = "OAuth App ID (required when using --oauth)")] + public string OAuthAppId { get; set; } = "6400aa06db64de001a31c8a9"; + + [Option(CommandOptionType.SingleValue, ShortName = "", LongName = "client-id", Description = "OAuth Client ID (required when using --oauth)")] + public string OAuthClientId { get; set; } = "Ie0FEfTzlfAHL4xM"; + + [Option(CommandOptionType.SingleValue, ShortName = "", LongName = "redirect-uri", Description = "OAuth Redirect URI (required when using --oauth)")] + public string OAuthRedirectUri { get; set; } = "http://localhost:8184"; + + [Option(CommandOptionType.SingleValue, ShortName = "", LongName = "client-secret", Description = "OAuth Client Secret (optional for public clients)")] + public string OAuthClientSecret { get; set; } + [Option(CommandOptionType.SingleValue, ShortName = "", LongName = "scopes", Description = "OAuth Scopes (optional, space-separated)")] + public string OAuthScopes { get; set; } + [Option(CommandOptionType.SingleValue, ShortName = "b", LongName = "branch", Description = "The branch header in the API request to fetch or manage modules located within specific branches.")] public string Branch { get; set; } @@ -71,21 +88,72 @@ public class ModelGenerator public async Task OnExecute(CommandLineApplication app, IConsole console) { + // Validate authentication parameters + if (UseOAuth) + { + if (string.IsNullOrEmpty(OAuthClientId) || string.IsNullOrEmpty(OAuthRedirectUri)) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.Error.WriteLine("OAuth mode requires --client-id and --redirect-uri parameters (--client-secret is optional)"); + Console.ResetColor(); + return Program.ERROR; + } + } + else + { + if (string.IsNullOrEmpty(Authtoken)) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.Error.WriteLine("You must specify the Contentstack authtoken for the Content Management API"); + Console.ResetColor(); + return Program.ERROR; + } + } + // Parse OAuth scopes if provided + string[] oauthScopes = null; + if (UseOAuth && !string.IsNullOrEmpty(OAuthScopes)) + { + oauthScopes = OAuthScopes.Split(' ', StringSplitOptions.RemoveEmptyEntries); + } var options = new ContentstackOptions { ApiKey = ApiKey, Authtoken = Authtoken, Host = Host, - Branch = Branch + Branch = Branch, + IsOAuth = UseOAuth, + OAuthClientId = OAuthClientId, + OAuthClientSecret = OAuthClientSecret, + OAuthRedirectUri = OAuthRedirectUri, + OAuthAppId = OAuthAppId, + OAuthScopes = oauthScopes }; + + // Handle OAuth flow if enabled + if (UseOAuth) + { + try + { + await HandleOAuthFlow(options, console); + } + catch (Exception e) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.Error.WriteLine($"OAuth authentication failed: {e.Message}"); + Console.ResetColor(); + return Program.ERROR; + } + } + var client = new ContentstackClient(options); try { Console.WriteLine($"Fetching Stack details for API Key {ApiKey}"); - stack = await client.GetStack(); - } catch (Exception e) + stack = await client.GetStack(); + } + catch (Exception e) { Console.WriteLine(e); Console.ForegroundColor = ConsoleColor.Red; @@ -117,9 +185,9 @@ public async Task OnExecute(CommandLineApplication app, IConsole console) while (totalCount > skip) { - Console.WriteLine($"{skip} Content Types Fetched."); - totalCount = await getContentTypes(client, skip); - skip += 100; + Console.WriteLine($"{skip} Content Types Fetched."); + totalCount = await getContentTypes(client, skip); + skip += 100; } Console.WriteLine($"Total {totalCount} Content Types fetched."); @@ -167,6 +235,25 @@ public async Task OnExecute(CommandLineApplication app, IConsole console) Console.ResetColor(); Console.WriteLine($"Opening {dir.FullName}"); OpenFolderatPath(dir.FullName); + + // Logout from OAuth if OAuth was used + if (UseOAuth) + { + try + { + Console.WriteLine(); + Console.WriteLine("Logging out from OAuth..."); + await OAuthService.LogoutAsync(options); + Console.WriteLine("OAuth logout successful!"); + } + catch (Exception e) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($"Warning: OAuth logout failed: {e.Message}"); + Console.ResetColor(); + } + } + return Program.OK; } @@ -203,7 +290,8 @@ private string GetDatatype(Field field, string contentTypeName) case "boolean": return "bool"; case "json": - if (field.FieldMetadata != null && field.FieldMetadata.IsJsonRTE) { + if (field.FieldMetadata != null && field.FieldMetadata.IsJsonRTE) + { return "Node"; } return "dynamic"; @@ -229,13 +317,13 @@ private 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) { - return $"List<{ dataType }>"; + return $"List<{dataType}>"; } if (field.FieldMetadata != null && field.FieldMetadata.RefMultiple) { - return $"List<{ dataType }>"; + return $"List<{dataType}>"; } - return (field.IsMultiple ? $"List<{ dataType }>" : dataType); + return (field.IsMultiple ? $"List<{dataType}>" : dataType); } private string GetDatatypeForContentType(Field field) @@ -279,7 +367,7 @@ private string FirstLetterToUpperCase(string s) { if (string.IsNullOrEmpty(s)) return s; - var value = Regex.Replace(s, @"[\d-]","").Replace("_", " "); + var value = Regex.Replace(s, @"[\d-]", "").Replace("_", " "); return CultureInfo.InvariantCulture.TextInfo.ToTitleCase(value).Replace(" ", ""); } private void OpenFolderatPath(string path) @@ -294,7 +382,7 @@ private void OpenFolderatPath(string path) private string GetDataTypeForModularBlock(Field field, string contentTypeName) { - return $"{ModularBlockPrefix}{contentTypeName}{FormatClassName(field.DisplayName)}".Replace(" ",""); + return $"{ModularBlockPrefix}{contentTypeName}{FormatClassName(field.DisplayName)}".Replace(" ", ""); } private string GetDataTypeForGroup(Field field, string contentTypeName) @@ -302,7 +390,7 @@ private string GetDataTypeForGroup(Field field, string contentTypeName) return $"{GroupPrefix}{contentTypeName}{FormatClassName(field.DisplayName)}".Replace(" ", ""); } - private void CreateEmbeddedObjectClass(string NameSpace, DirectoryInfo directoryInfo) + private void CreateEmbeddedObjectClass(string NameSpace, DirectoryInfo directoryInfo) { string ConverterName = "IEmbeddedObjectConverter"; var file = shouldCreateFile(ConverterName, directoryInfo); @@ -347,11 +435,13 @@ private void CreateEmbeddedObjectClass(string NameSpace, DirectoryInfo directory sb.AppendLine($" {(includeElse ? "else " : "")}if ((string{nullableString()})obj.GetValue(\"_content_type_uid\") == \"{contentType.Uid}\")"); sb.AppendLine(" {"); sb.AppendLine($" {FormatClassName(contentType.Title)}{nullableString()} {FirstLetterToUpperCase(contentType.Uid)} = obj.ToObject<{FormatClassName(contentType.Title)}>();"); - if (IsNullable) { + if (IsNullable) + { sb.AppendLine($" if ({FirstLetterToUpperCase(contentType.Uid)} != null) {{"); } sb.AppendLine($" target.Add({FirstLetterToUpperCase(contentType.Uid)});"); - if (IsNullable) { + if (IsNullable) + { sb.AppendLine($" }}"); } sb.AppendLine(" }"); @@ -361,11 +451,13 @@ private void CreateEmbeddedObjectClass(string NameSpace, DirectoryInfo directory sb.AppendLine($" {(includeElse ? "else " : "")}if ((string{nullableString()})obj.GetValue(\"_content_type_uid\") == \"sys_assets\")"); sb.AppendLine(" {"); sb.AppendLine($" Asset{nullableString()} asset = obj.ToObject();"); - if (IsNullable) { + if (IsNullable) + { sb.AppendLine($" if (asset != null) {{"); } sb.AppendLine($" target.Add(asset);"); - if (IsNullable) { + if (IsNullable) + { sb.AppendLine($" }}"); } sb.AppendLine(" }"); @@ -376,12 +468,14 @@ private void CreateEmbeddedObjectClass(string NameSpace, DirectoryInfo directory sb.AppendLine($" public override void WriteJson(JsonWriter writer, {className}{nullableString()} value, JsonSerializer serializer)"); sb.AppendLine(" {"); - if (IsNullable) { + if (IsNullable) + { sb.AppendLine($" if (value != null) {{"); } sb.AppendLine(" JToken t = JToken.FromObject(value);"); sb.AppendLine(" t.WriteTo(writer);"); - if (IsNullable) { + if (IsNullable) + { sb.AppendLine($" }}"); } sb.AppendLine(" }"); @@ -631,7 +725,7 @@ private void CreateFile(string contentTypeName, string nameSpace, Contenttype co var sb = new StringBuilder(); // Adding using at start of file AddUsingDirectives(usingDirectiveList, sb); - + // Creating namespace AddNameSpace($"{nameSpace}.{directoryInfo.Name}", sb); @@ -647,7 +741,7 @@ 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($" [JsonProperty(propertyName: \"uid\")]"); sb.AppendLine($" public string{nullableString()} Uid {{ get; set; }}"); sb.AppendLine($" [JsonProperty(propertyName: \"_content_type_uid\")]"); sb.AppendLine($" public string{nullableString()} ContentTypeUid {{ get; set; }}"); @@ -793,15 +887,15 @@ private void AddParams(string contentType, List schema, in StringBuilder sb.AppendLine($" get"); sb.AppendLine($" {{"); - sb.AppendLine($" return this.{FirstLetterToUpperCase(field.Uid)}Store.{(field.IsMultiple ? "ToListHtml()" : "ToHtml()" )};"); + sb.AppendLine($" return this.{FirstLetterToUpperCase(field.Uid)}Store.{(field.IsMultiple ? "ToListHtml()" : "ToHtml()")};"); sb.AppendLine($" }}"); sb.AppendLine($" }}"); sb.AppendLine($" private {GetDatatypeForField(field, contentType)} {FirstLetterToUpperCase(field.Uid)}Store = \"\";"); continue; - + } - sb.AppendLine($" public {GetDatatypeForField(field, contentType)}{nullableString()} {FirstLetterToUpperCase(field.Uid)} {{ get; set; }}"); + sb.AppendLine($" public {GetDatatypeForField(field, contentType)}{nullableString()} {FirstLetterToUpperCase(field.Uid)} {{ get; set; }}"); } } @@ -1097,11 +1191,13 @@ private void CreateModularBlockConverter(string nameSpace, string className, Dic 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) { + if (IsNullable) + { sb.AppendLine($" if (token != null) {{"); } sb.AppendLine(" serializer.Populate(token.CreateReader(), target);"); - if (IsNullable) { + if (IsNullable) + { sb.AppendLine($" }}"); } sb.AppendLine(" return target;"); @@ -1109,12 +1205,14 @@ private void CreateModularBlockConverter(string nameSpace, string className, Dic sb.AppendLine($" public override void WriteJson(JsonWriter writer, {className}{nullableString()} value, JsonSerializer serializer)"); sb.AppendLine(" {"); - if (IsNullable) { + if (IsNullable) + { sb.AppendLine($" if (value != null) {{"); } sb.AppendLine(" JToken t = JToken.FromObject(value);"); sb.AppendLine(" t.WriteTo(writer);"); - if (IsNullable) { + if (IsNullable) + { sb.AppendLine($" }}"); } sb.AppendLine(" }"); @@ -1127,5 +1225,57 @@ private void CreateModularBlockConverter(string nameSpace, string className, Dic } } } + + /// + /// Handles the OAuth authentication flow + /// + /// OAuth configuration options + /// Console for user interaction + private async Task HandleOAuthFlow(ContentstackOptions options, IConsole console) + { + // Generate PKCE code verifier + string codeVerifier = OAuthService.GenerateCodeVerifier(); + + // Generate authorization URL + string authUrl = OAuthService.GenerateAuthorizationUrl(options, codeVerifier); + + Console.WriteLine("OAuth Authentication Required"); + Console.WriteLine("============================="); + Console.WriteLine(); + Console.WriteLine("Please open the following URL in your browser to authorize the application:"); + Console.WriteLine(); + Console.ForegroundColor = ConsoleColor.Cyan; + Console.WriteLine(authUrl); + Console.ResetColor(); + Console.WriteLine(); + Console.WriteLine("After authorization, you will be redirected to a local URL."); + Console.WriteLine("Please copy the 'code' parameter from the redirect URL and paste it here:"); + Console.WriteLine(); + + // Get authorization code from user + Console.Write("Authorization code: "); + string authorizationCode = Console.ReadLine(); + + if (string.IsNullOrEmpty(authorizationCode)) + { + throw new InvalidOperationException("Authorization code is required"); + } + + Console.WriteLine(); + Console.WriteLine("Exchanging authorization code for access token..."); + + // Exchange code for token + var tokenResponse = await OAuthService.ExchangeCodeForTokenAsync(options, authorizationCode, codeVerifier); + + // Update options with tokens + options.AccessToken = tokenResponse.AccessToken; + options.RefreshToken = tokenResponse.RefreshToken; + options.TokenExpiresAt = tokenResponse.ExpiresAt; + options.Authorization = $"Bearer {tokenResponse.AccessToken}"; + + Console.WriteLine("OAuth authentication successful!"); + Console.WriteLine($"Access token expires at: {tokenResponse.ExpiresAt:yyyy-MM-dd HH:mm:ss} UTC"); + Console.WriteLine(); + } } } diff --git a/contentstack.model.generator/contentstack.model.generator.csproj b/contentstack.model.generator/contentstack.model.generator.csproj index 9d4f69c..75cdf07 100644 --- a/contentstack.model.generator/contentstack.model.generator.csproj +++ b/contentstack.model.generator/contentstack.model.generator.csproj @@ -1,78 +1,84 @@ - - - - Exe - net7.0 - contentstack.model.generator - Copyright © 2012-2024 Contentstack. All Rights Reserved - en-US - https://github.com/contentstack/contentstack-model-generator/blob/master/LICENSE - README.md - Contentstacks - https://github.com/contentstack/contentstack-model-generator.git - true - ./nupkg - true - 0.4.6 - Contentstack - 0.4.6 - Contentstack.Model.Generator - LICENSE.txt - Modular block with Global field issue resolved - true - true - v0.4.6 - Release;Debug - - - - None - false - - - - - - - - - - - - - - - - CHANGELOG.md - - - README.md - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + Exe + net7.0 + contentstack.model.generator + Copyright © 2012-2024 Contentstack. All Rights Reserved + en-US + https://github.com/contentstack/contentstack-model-generator/blob/master/LICENSE + README.md + Contentstacks + https://github.com/contentstack/contentstack-model-generator.git + true + ./nupkg + true + 0.4.6 + Contentstack + 0.4.6 + Contentstack.Model.Generator + LICENSE.txt + Modular block with Global field issue resolved + true + true + v0.4.6 + Release;Debug + + + + None + false + + + + + + + + + <_Parameter1>contentstack.model.generator.tests + + + + + + + + + + + + + CHANGELOG.md + + + README.md + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 5e355e9c9a3bd182023361dc99470b3cf452358a Mon Sep 17 00:00:00 2001 From: raj pandey Date: Tue, 30 Sep 2025 12:57:59 +0530 Subject: [PATCH 2/5] Version bump --- README.md | 2 +- contentstack.model.generator/ModelGenerator.cs | 2 +- .../contentstack.model.generator.csproj | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 6a8486b..083d2a9 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ 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.4.6 +Contentstack Model Generator v0.5.0 ===================================== OAuth Authentication Required diff --git a/contentstack.model.generator/ModelGenerator.cs b/contentstack.model.generator/ModelGenerator.cs index 641a530..9c2890b 100644 --- a/contentstack.model.generator/ModelGenerator.cs +++ b/contentstack.model.generator/ModelGenerator.cs @@ -68,7 +68,7 @@ public class ModelGenerator [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; } - [VersionOption("0.4.6")] + [VersionOption("0.5.0")] public bool Version { get; } private readonly string _templateStart = @"using System; diff --git a/contentstack.model.generator/contentstack.model.generator.csproj b/contentstack.model.generator/contentstack.model.generator.csproj index 75cdf07..6622ec6 100644 --- a/contentstack.model.generator/contentstack.model.generator.csproj +++ b/contentstack.model.generator/contentstack.model.generator.csproj @@ -13,15 +13,15 @@ true ./nupkg true - 0.4.6 + 0.5.0 Contentstack - 0.4.6 + 0.5.0 Contentstack.Model.Generator LICENSE.txt Modular block with Global field issue resolved true true - v0.4.6 + v0.5.0 Release;Debug From 40640318ba5b04ed6e65dc0148d7c23617bba441 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Tue, 30 Sep 2025 15:44:02 +0530 Subject: [PATCH 3/5] Updated License --- LICENSE | 2 +- OAuth.md | 27 +++++++++++++++++ README.md | 30 +------------------ contentstack.model.generator/LICENSE.txt | 2 +- .../contentstack.model.generator.csproj | 2 +- 5 files changed, 31 insertions(+), 32 deletions(-) create mode 100644 OAuth.md diff --git a/LICENSE b/LICENSE index 3333caa..501f936 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright © 2012-2024 Contentstack. All Rights Reserved +Copyright © 2012-2025 Contentstack. All Rights Reserved Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/OAuth.md b/OAuth.md new file mode 100644 index 0000000..04c8862 --- /dev/null +++ b/OAuth.md @@ -0,0 +1,27 @@ +## OAuth 2.0 Setup + +### Prerequisites +1. **Contentstack Account**: You need a Contentstack account with appropriate permissions +2. **OAuth App**: Create an OAuth application in your Contentstack dashboard +3. **Redirect URI**: Configure a valid redirect URI (e.g., `http://localhost:8184`) + +### OAuth Flow +1. **Authorization**: The tool displays the Contentstack OAuth authorization URL for you to open manually +2. **Authentication**: Open the URL in your browser, log in to your Contentstack account and authorize the application +3. **Callback**: You'll be redirected to your specified redirect URI with an authorization code +4. **Code Entry**: Copy the authorization code from the redirect URL and paste it into the tool +5. **Token Exchange**: The tool automatically exchanges the code for an access token +6. **Model Generation**: The tool fetches your content types and generates models +7. **Logout**: The tool automatically logs out and clears tokens + +### Security Features +- **PKCE Support**: Uses Proof Key for Code Exchange for enhanced security +- **Client Secret Optional**: Supports both confidential and public clients +- **Automatic Token Management**: Handles token refresh and expiration +- **Secure Logout**: Automatically clears tokens after model generation + +### Troubleshooting OAuth +- **Invalid Redirect URI**: Ensure the redirect URI matches exactly what's configured in your OAuth app +- **Client ID/Secret Issues**: Verify your OAuth app credentials +- **Network Issues**: Check your internet connection and Contentstack service status +- **Permission Issues**: Ensure your account has the necessary permissions for the stack diff --git a/README.md b/README.md index 083d2a9..99ca2b3 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Once you install ```contentstack.model.generator``` run ```--help``` to view av The Contentstack Model Generator supports two authentication methods: -1. **Traditional API Key Authentication** (default) +1. **Traditional Authtoken Authentication** (default) 2. **OAuth 2.0 Authentication** ### Command Line Options @@ -140,34 +140,6 @@ Logging out from OAuth... OAuth logout successful! ``` -## OAuth 2.0 Setup - -### Prerequisites -1. **Contentstack Account**: You need a Contentstack account with appropriate permissions -2. **OAuth App**: Create an OAuth application in your Contentstack dashboard -3. **Redirect URI**: Configure a valid redirect URI (e.g., `http://localhost:8184`) - -### OAuth Flow -1. **Authorization**: The tool displays the Contentstack OAuth authorization URL for you to open manually -2. **Authentication**: Open the URL in your browser, log in to your Contentstack account and authorize the application -3. **Callback**: You'll be redirected to your specified redirect URI with an authorization code -4. **Code Entry**: Copy the authorization code from the redirect URL and paste it into the tool -5. **Token Exchange**: The tool automatically exchanges the code for an access token -6. **Model Generation**: The tool fetches your content types and generates models -7. **Logout**: The tool automatically logs out and clears tokens - -### Security Features -- **PKCE Support**: Uses Proof Key for Code Exchange for enhanced security -- **Client Secret Optional**: Supports both confidential and public clients -- **Automatic Token Management**: Handles token refresh and expiration -- **Secure Logout**: Automatically clears tokens after model generation - -### Troubleshooting OAuth -- **Invalid Redirect URI**: Ensure the redirect URI matches exactly what's configured in your OAuth app -- **Client ID/Secret Issues**: Verify your OAuth app credentials -- **Network Issues**: Check your internet connection and Contentstack service status -- **Permission Issues**: Ensure your account has the necessary permissions for the stack - ### MIT License Copyright (c) 2012-2025 Contentstack diff --git a/contentstack.model.generator/LICENSE.txt b/contentstack.model.generator/LICENSE.txt index 3333caa..501f936 100644 --- a/contentstack.model.generator/LICENSE.txt +++ b/contentstack.model.generator/LICENSE.txt @@ -1,6 +1,6 @@ MIT License -Copyright © 2012-2024 Contentstack. All Rights Reserved +Copyright © 2012-2025 Contentstack. All Rights Reserved Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/contentstack.model.generator/contentstack.model.generator.csproj b/contentstack.model.generator/contentstack.model.generator.csproj index 6622ec6..16f8f6d 100644 --- a/contentstack.model.generator/contentstack.model.generator.csproj +++ b/contentstack.model.generator/contentstack.model.generator.csproj @@ -4,7 +4,7 @@ Exe net7.0 contentstack.model.generator - Copyright © 2012-2024 Contentstack. All Rights Reserved + Copyright © 2012-2025 Contentstack. All Rights Reserved en-US https://github.com/contentstack/contentstack-model-generator/blob/master/LICENSE README.md From 5f8b3045386f6aeee391f91401381ede157604e4 Mon Sep 17 00:00:00 2001 From: Aravind Kumar Date: Tue, 30 Sep 2025 16:19:43 +0530 Subject: [PATCH 4/5] Update sca-scan.yml --- .github/workflows/sca-scan.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sca-scan.yml b/.github/workflows/sca-scan.yml index 4fa4560..eda6619 100644 --- a/.github/workflows/sca-scan.yml +++ b/.github/workflows/sca-scan.yml @@ -7,9 +7,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master + - name: Run Dotnet Restore + run: | + dotnet restore ./contentstack.model.generator/contentstack.model.generator.sln - name: Run Snyk to check for vulnerabilities uses: snyk/actions/dotnet@master env: SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }} with: - args: --fail-on=all + args: --file=contentstack.model.generator/obj/project.assets.json From 229d1f50f9fd659d381197c4547d90cbf39a71bf Mon Sep 17 00:00:00 2001 From: harshithad0703 <104908717+harshithad0703@users.noreply.github.com> Date: Mon, 6 Oct 2025 15:59:45 +0530 Subject: [PATCH 5/5] Change branch restriction from 'next' to 'staging' --- .github/workflows/check-branch.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-branch.yml b/.github/workflows/check-branch.yml index 4c087e5..2332f0d 100644 --- a/.github/workflows/check-branch.yml +++ b/.github/workflows/check-branch.yml @@ -8,13 +8,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Comment PR - if: github.base_ref == 'master' && github.head_ref != 'next' + 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 next branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch. + 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 != 'next' + if: github.base_ref == 'master' && github.head_ref != 'staging' 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 next branch. Our team will then review the changes and work with you to ensure a successful merge into the master branch." + 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