From 899905cae3d73a1760d1c82f02316302d1b87914 Mon Sep 17 00:00:00 2001 From: raj pandey Date: Tue, 30 Sep 2025 12:56:00 +0530 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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