diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..30556aa --- /dev/null +++ b/.gitattributes @@ -0,0 +1,104 @@ +*.txt text eol=crlf diff +*.xml text eol=crlf diff +*.xaml text eol=crlf diff +*.dll -text -diff +*.pdb -text -diff +*.zip -text -diff +*.config text eol=crlf diff +*.docx -text -diff +*.sample text eol=crlf diff +*.chm -text -diff +*.jar -text -diff +*.sln text eol=crlf diff +*.vssscc text eol=crlf diff +*.testsettings text eol=crlf diff +*.vsmdi text eol=crlf diff +*.targets text eol=crlf diff +*.bat text eol=crlf diff +*.idx -text -diff +*.pack -text -diff +*.698_20141017 text eol=crlf diff +*.181_20141104 text eol=crlf diff +*.698_20141105 text eol=crlf diff +*.701_20141106 text eol=crlf diff +*.488_20141110 text eol=crlf diff +*.197_20141117 text eol=crlf diff +*.190_20141016 text eol=crlf diff +*.csproj text eol=crlf diff +*.vspscc text eol=crlf diff +*.cs text eol=crlf diff +*.settings text eol=crlf diff +*.Config text eol=crlf diff +*.exe -text -diff +*.aspx text eol=crlf diff +*.vb text eol=crlf diff +*.asax text eol=crlf diff +*.ashx text eol=crlf diff +*.ico -text -diff +*.html text eol=crlf diff +*.vbproj text eol=crlf diff +*.csv text eol=crlf diff +*.loadtest text eol=crlf diff +*.webtest text eol=crlf diff +*.cd text eol=crlf diff +*.jmx text eol=crlf diff +*.css text eol=crlf diff +*.json text eol=crlf diff +*.js text eol=crlf diff +*.XML text eol=crlf diff +*.prefs text eol=crlf diff +*.tsv text eol=crlf diff +*.xlsx -text -diff +*.au3 text eol=crlf diff +*.donotuse -text -diff +*.png -text -diff +*.nupkg -text -diff +*.htm text eol=crlf diff +*.browser text eol=crlf diff +*.exclude text eol=crlf diff +*.gif -text -diff +*.jpg -text -diff +*.mmdb -text -diff +*.myapp text eol=crlf diff +*.resx text eol=crlf diff +*.rb text eol=crlf diff +*.yaml text eol=crlf diff +*.manifest text eol=crlf diff +*.sql text eol=crlf diff +*.markdown text eol=crlf diff +*.launch text eol=crlf diff +*.crx -text -diff +*.xpi -text -diff +*.ps1 text eol=crlf diff +*.ascx text eol=crlf diff +*.master text eol=crlf diff +*.disco text eol=crlf diff +*.discomap text eol=crlf diff +*.wsdl text eol=crlf diff +*.htc text eol=crlf diff +*.pubxml text eol=crlf diff +*.datasource text eol=crlf diff +*.map text eol=crlf diff +*.ts text eol=crlf diff +*.cshtml text eol=crlf diff +*.nuspec text eol=crlf diff +*.pp text eol=crlf diff +*.opts text eol=crlf diff +*.erb text eol=crlf diff +*.pri -text -diff +*.inc text eol=crlf diff +*.db -text -diff +*.html-20090130 text eol=crlf diff +*.Master text eol=crlf diff +*.rej text eol=crlf diff +*.psd -text -diff +*.bmp -text -diff +*.properties text eol=crlf diff +*.task text eol=crlf diff +*.project text eol=crlf diff +*.classpath text eol=crlf diff +*.buildpath text eol=crlf diff +*.java text eol=crlf diff +*.sql encoding=UTF-8 +*.cshtml encoding=UTF-8 +*.cs encoding=UTF-8 \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..971a07a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,34 @@ +name: .NET Core Build + +on: + pull_request: + +env: + NETCORE_VERSION: '8.0' + GIT_REPO_ROOT: src + SOLUTION_FILE: Agoda.GrapqhlGen.sln + DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2 + +jobs: + + build: + name: Build Package + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup .NET Core SDK ${{ env.NETCORE_VERSION }} + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.NETCORE_VERSION }} + + - name: Restore + working-directory: ${{ env.GIT_REPO_ROOT }} + run: dotnet restore ${{ env.SOLUTION_FILE }} + + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v1 + + - name: Build + working-directory: ${{ env.GIT_REPO_ROOT }} + run: dotnet build ${{ env.SOLUTION_FILE }} --configuration Debug --no-restore \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..fb9dd42 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,98 @@ +name: .NET Core Build and Publish + +on: + push: + +env: + NETCORE_VERSION: '8.0' + GIT_REPO_ROOT: src + MAJOR_MINOR_VERSION: 1.0. + SOLUTION_FILE: Agoda.GrapqhlGen.sln + DOTNET_ROLL_FORWARD_ON_NO_CANDIDATE_FX: 2 + +jobs: + + build: + name: Build Package + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup .NET Core SDK ${{ env.NETCORE_VERSION }} + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.NETCORE_VERSION }} + + - name: Restore + working-directory: ${{ env.GIT_REPO_ROOT }} + run: dotnet restore ${{ env.SOLUTION_FILE }} + + - name: Add MSBuild to PATH + uses: microsoft/setup-msbuild@v1 + + - name: Build + working-directory: ${{ env.GIT_REPO_ROOT }} + run: dotnet build ${{ env.SOLUTION_FILE }} --configuration Release --no-restore + + - name: Pack Release + if: github.ref == 'refs/heads/master' + working-directory: ${{ env.GIT_REPO_ROOT }} + run: dotnet pack ${{ env.SOLUTION_FILE }} --configuration Release -o finalpackage --no-build -p:PackageVersion=${{ env.MAJOR_MINOR_VERSION }}${{ github.run_number }} + + - name: Pack Preview + if: github.ref != 'refs/heads/master' + working-directory: ${{ env.GIT_REPO_ROOT }} + run: dotnet pack ${{ env.SOLUTION_FILE }} --configuration Release -o finalpackage --no-build -p:PackageVersion=${{ env.MAJOR_MINOR_VERSION }}${{ github.run_number }}-preview + + - name: Publish artifact + uses: actions/upload-artifact@master + with: + name: nupkg + path: ${{ env.GIT_REPO_ROOT }}/finalpackage + + deploy: + needs: build + name: Deploy Packages + runs-on: ubuntu-latest + steps: + - name: Download Package artifact + uses: actions/download-artifact@master + with: + name: nupkg + path: ./nupkg + + - name: Setup NuGet + uses: NuGet/setup-nuget@v1.0.5 + with: + nuget-api-key: ${{ secrets.KLONDIKE_API_KEY }} + nuget-version: latest + + - name: Setup .NET Core SDK + uses: actions/setup-dotnet@v1 + with: + dotnet-version: ${{ env.NETCORE_VERSION }} + + - name: Push to NuGet + run: dotnet nuget push nupkg/**/*.nupkg -k ${{ secrets.NUGET_API_KEY }} -s https://nuget.org --skip-duplicate + + release: + needs: deploy + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/master' + steps: + - name: Create Draft Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ env.MAJOR_MINOR_VERSION }}${{ github.run_number }} + release_name: ${{ env.MAJOR_MINOR_VERSION }}${{ github.run_number }} + draft: true + prerelease: false + + - uses: eregon/publish-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + release_id: ${{ steps.create_release.outputs.id }} \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 83e1765..6a0147f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,72 @@ -# dotnet-cli-grapqhl-gen -Grapqhl Generation dotnet cli Tool +# dotnet-cli-graphql-gen: GraphQL Code Generation Made Easy! + +## Overview + +Welcome to dotnet-cli-graphql-gen, an open-source CLI tool that wraps npm package graphql-codegen to add some missing parts. + +## Requirements + +- .NET 8.0 or higher (Because we believe in staying current!) +- pnpm (Don't worry, we'll install it if you don't have it) + +## Installation + +### Global Tool Installation + +Install it globally (because great tools should be available everywhere): +```bash +dotnet tool install --global Agoda.GrapqhlGen +``` + +### Local Project Installation + +Or keep it project-specific: +```bash +dotnet new tool-manifest # if you haven't already +dotnet tool install Agoda.GrapqhlGen +``` + +## Quick Start + +Generate your GraphQL client code with a single command: + +```bash +grapqhlgen \ + --schema-url "https://your.graphql.api" \ + --input-path "./graphql" \ + --output-path "./Generated" \ + --namespace "YourCompany.YourProject" \ + --headers "API-Key: your-key" +``` + +## Command Options + +- `--schema-url` (Required): URL of your GraphQL schema +- `--input-path` (Required): Directory containing your .graphql files +- `--output-path` (Required): Where to save generated files +- `--namespace` (Optional): Base namespace for generated code (Default: "Generated") +- `--headers` (Optional): Headers for schema request (Format: "Key: Value") +- `--template` (Optional): Code generation template (Default: "typescript") +- `--model-file` (Optional): Name of the generated models file (Default: "Models.cs") +- `--log-level` (Optional): Set logging verbosity (Default: "Information") + +## Contributing + +We love contributions! Whether you're fixing bugs, improving documentation, or adding new features, check out our [Contributing Guide](CONTRIBUTING.md) for details on getting started. + +## Best Practices + +- Keep your GraphQL queries in separate .graphql files +- Use meaningful names for your queries and mutations +- Organize your GraphQL files by feature or domain +- Version control your GraphQL files alongside your code + +## And Finally... + +Remember, in the world of GraphQL, there are two types of developers: those who use code generation tools, and those who wish they had started using them sooner! With dotnet-cli-graphql-gen, you'll never want to write GraphQL client code by hand again. + +Happy coding, and may your queries always resolve! 🚀 + +## License + +Apache 2.0 - feel free to use this tool in your projects, whether personal or commercial. Just don't blame us if your GraphQL queries start writing themselves! 😉 \ No newline at end of file diff --git a/src/Agoda.GrapqhlGen.Tests/Agoda.GrapqhlGen.Tests.csproj b/src/Agoda.GrapqhlGen.Tests/Agoda.GrapqhlGen.Tests.csproj new file mode 100644 index 0000000..71306fb --- /dev/null +++ b/src/Agoda.GrapqhlGen.Tests/Agoda.GrapqhlGen.Tests.csproj @@ -0,0 +1,26 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/src/Agoda.GrapqhlGen.Tests/CodeGeneratorTests.cs b/src/Agoda.GrapqhlGen.Tests/CodeGeneratorTests.cs new file mode 100644 index 0000000..ac0b414 --- /dev/null +++ b/src/Agoda.GrapqhlGen.Tests/CodeGeneratorTests.cs @@ -0,0 +1,351 @@ +using NUnit.Framework; +using NSubstitute; +using Shouldly; +using System.Text; + +namespace Agoda.GrapqhlGen.Tests; + +[TestFixture] +public class CodeGeneratorTests +{ + private ICommandExecutor _commandExecutor = null!; + private string _tempPath = null!; + private CodeGenerator _generator = null!; + private const string SchemaUrl = "https://test.graphql"; + + [SetUp] + public void Setup() + { + _commandExecutor = Substitute.For(); + _tempPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempPath); + + _generator = new CodeGenerator( + SchemaUrl, + _tempPath, + _tempPath, + "Test.Namespace", + new Dictionary { { "API-Key", "test-key" } }, + "typescript", + "Models.cs", + _commandExecutor); + } + + [TearDown] + public void Cleanup() + { + if (Directory.Exists(_tempPath)) + { + Directory.Delete(_tempPath, true); + } + } + + private void SetupDefaultCommandExecutor() + { + _commandExecutor + .ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var command = callInfo.ArgAt(0); + var args = callInfo.ArgAt(1); + Console.WriteLine($"Mock received command: {command} {args}"); + + if (args.Contains("graphql-codegen")) + { + var classesPath = Path.Combine(_tempPath, "Classes.cs"); + Console.WriteLine($"Creating Classes.cs at {classesPath}"); + File.WriteAllText(classesPath, @" +namespace Generated +{ + #region input types + public class TestModel {} + #endregion + + #region TestQuery + public class TestQuery {} + #endregion +}"); + Console.WriteLine($"Classes.cs created: {File.Exists(classesPath)}"); + } + return Task.CompletedTask; + }); + } + + [Test] + public async Task GenerateAsync_ShouldCreateExpectedFiles() + { + // Arrange + SetupMockFiles(); + SetupDefaultCommandExecutor(); + + // Act + await _generator.GenerateAsync(); + + // Debug Output + Console.WriteLine($"Temp Path: {_tempPath}"); + Console.WriteLine($"Files in directory:"); + foreach (var file in Directory.GetFiles(_tempPath)) + { + Console.WriteLine($"- {file}"); + } + + // Assert + var modelsPath = Path.Combine(_tempPath, "Models.cs"); + File.Exists(modelsPath).ShouldBeTrue($"Models.cs should exist at {modelsPath}"); + + var queryPath = Path.Combine(_tempPath, "TestQuery.generated.cs"); + File.Exists(queryPath).ShouldBeTrue($"TestQuery.generated.cs should exist at {queryPath}"); + } + + [Test] + public async Task GenerateAsync_ShouldReplaceNamespaceCorrectly() + { + // Arrange + SetupMockFiles(); + SetupDefaultCommandExecutor(); + + // Act + await _generator.GenerateAsync(); + + // Assert + var generatedContent = File.ReadAllText(Path.Combine(_tempPath, "Models.cs")); + generatedContent.ShouldContain("namespace Test.Namespace"); + generatedContent.ShouldNotContain("namespace Generated"); + } + + [Test] + public async Task GenerateAsync_WithNoInputTypesRegion_ShouldNotCreateModelsFile() + { + // Arrange + SetupMockFiles(); + + _commandExecutor + .ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + if (callInfo.ArgAt(1).Contains("graphql-codegen")) + { + File.WriteAllText(Path.Combine(_tempPath, "Classes.cs"), @" +namespace Generated +{ + #region TestQuery + public class TestQuery {} + #endregion +}"); + } + return Task.CompletedTask; + }); + + // Act + await _generator.GenerateAsync(); + + // Assert + File.Exists(Path.Combine(_tempPath, "Models.cs")).ShouldBeFalse(); + File.Exists(Path.Combine(_tempPath, "TestQuery.generated.cs")).ShouldBeTrue(); + } + + [Test] + public async Task GenerateAsync_WithEmptyHeaders_ShouldNotIncludeHeadersInCommand() + { + // Arrange + var generator = new CodeGenerator( + SchemaUrl, + _tempPath, + _tempPath, + "Test.Namespace", + null, + "typescript", + "Models.cs", + _commandExecutor); + + SetupMockFiles(); + + // Setup command executor to create Classes.cs + _commandExecutor + .ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var command = callInfo.ArgAt(0); + var args = callInfo.ArgAt(1); + + if (args.Contains("graphql-codegen")) + { + var content = @" +namespace Generated +{ + #region input types + public class TestModel {} + #endregion + + #region TestQuery + public class TestQuery {} + #endregion +}"; + File.WriteAllText(Path.Combine(_tempPath, "Classes.cs"), content); + } + return Task.CompletedTask; + }); + + // Act + await generator.GenerateAsync(); + + // Assert + // Check that none of the pnpm commands contained "--header" + await _commandExecutor.Received().ExecuteAsync("pnpm", + Arg.Is(s => s == "install @graphql-codegen/cli @graphql-codegen/typescript")); + + await _commandExecutor.Received().ExecuteAsync("pnpm", + Arg.Is(s => + s.StartsWith("graphql-codegen") && + !s.Contains("--header"))); + + // Additional verification that no other pnpm commands were called + await _commandExecutor.Received(2).ExecuteAsync( + Arg.Is("pnpm"), + Arg.Any()); + + // Verify the files are generated correctly + File.Exists(Path.Combine(_tempPath, "Models.cs")).ShouldBeTrue(); + File.Exists(Path.Combine(_tempPath, "TestQuery.generated.cs")).ShouldBeTrue(); + } + + [Test] + public async Task GenerateAsync_ShouldCleanWorkingDirectory() + { + // Arrange + var existingGraphqlFile = Path.Combine(_tempPath, "existing.graphql"); + var existingClassesFile = Path.Combine(_tempPath, "Classes.cs"); + File.WriteAllText(existingGraphqlFile, ""); + File.WriteAllText(existingClassesFile, ""); + + // Setup command executor to create Classes.cs with required content + _commandExecutor + .ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + var command = callInfo.ArgAt(0); + var args = callInfo.ArgAt(1); + Console.WriteLine($"Mock received command: {command} {args}"); + + if (args.Contains("graphql-codegen")) + { + // Note the normalized line endings and careful spacing + var content = string.Join( + Environment.NewLine, + "using System;", + "using System.Collections.Generic;", + "", + "namespace Generated", + "{", + " #region input types", + " public class TestInputModel", + " {", + " public string Id { get; set; }", + " }", + " #endregion", + "", + " #region TestQuery", + " public class TestQuery", + " {", + " public string Name { get; set; }", + " }", + " #endregion", + "}" + ); + + var path = Path.Combine(_tempPath, "Classes.cs"); + File.WriteAllText(path, content, Encoding.UTF8); + + Console.WriteLine("Created Classes.cs with content:"); + Console.WriteLine(content); + Console.WriteLine($"File exists: {File.Exists(path)}"); + Console.WriteLine("File content verification:"); + Console.WriteLine(File.ReadAllText(path)); + } + return Task.CompletedTask; + }); + + // Act + await _generator.GenerateAsync(); + + // Output directory contents + Console.WriteLine("\nDirectory contents before assertion:"); + foreach (var file in Directory.GetFiles(_tempPath)) + { + Console.WriteLine($"File: {file}"); + if (Path.GetFileName(file).EndsWith(".cs")) + { + Console.WriteLine("Content:"); + Console.WriteLine(File.ReadAllText(file)); + } + } + + // Assert + var modelsPath = Path.Combine(_tempPath, "Models.cs"); + File.Exists(modelsPath).ShouldBeTrue($"Models.cs should exist at {modelsPath}"); + + if (File.Exists(modelsPath)) + { + var content = File.ReadAllText(modelsPath); + content.ShouldContain("namespace Test.Namespace"); + content.ShouldContain("TestInputModel"); + } + } + + [Test] + public async Task GenerateAsync_WhenClassesFileNotGenerated_ShouldThrow() + { + // Arrange + SetupMockFiles(); + + _commandExecutor + .ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + // Act & Assert + var exception = await Should.ThrowAsync(() => _generator.GenerateAsync()); + exception.Message.ShouldContain("Generated Classes.cs file not found"); + } + + [TestCase("using Agoda.CodeGen.GraphQL", "using Agoda.Graphql.Client")] + [TestCase("", "")] + public async Task GenerateAsync_ShouldReplaceExpectedStrings(string original, string expected) + { + // Arrange + SetupMockFiles(); + + _commandExecutor + .ExecuteAsync(Arg.Any(), Arg.Any()) + .Returns(callInfo => + { + if (callInfo.ArgAt(1).Contains("graphql-codegen")) + { + File.WriteAllText(Path.Combine(_tempPath, "Classes.cs"), $@" +namespace Generated +{{ + #region input types + {original} + public class TestModel {{}} + #endregion +}}"); + } + return Task.CompletedTask; + }); + + // Act + await _generator.GenerateAsync(); + + // Assert + var generatedContent = File.ReadAllText(Path.Combine(_tempPath, "Models.cs")); + generatedContent.ShouldContain(expected); + generatedContent.ShouldNotContain(original); + } + + private void SetupMockFiles() + { + var graphqlFile = Path.Combine(_tempPath, "query.graphql"); + Console.WriteLine($"Creating GraphQL file at {graphqlFile}"); + File.WriteAllText(graphqlFile, "query { test }"); + Console.WriteLine($"GraphQL file created: {File.Exists(graphqlFile)}"); + } +} \ No newline at end of file diff --git a/src/Agoda.GrapqhlGen.sln b/src/Agoda.GrapqhlGen.sln new file mode 100644 index 0000000..1a7592f --- /dev/null +++ b/src/Agoda.GrapqhlGen.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.11.35327.3 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Agoda.GrapqhlGen", "Agoda.GrapqhlGen\Agoda.GrapqhlGen.csproj", "{CEFF629E-5D42-4245-BC36-25B57627587A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Agoda.GrapqhlGen.Tests", "Agoda.GrapqhlGen.Tests\Agoda.GrapqhlGen.Tests.csproj", "{986253B2-863D-4187-A392-6BA689651F80}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CEFF629E-5D42-4245-BC36-25B57627587A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CEFF629E-5D42-4245-BC36-25B57627587A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CEFF629E-5D42-4245-BC36-25B57627587A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CEFF629E-5D42-4245-BC36-25B57627587A}.Release|Any CPU.Build.0 = Release|Any CPU + {986253B2-863D-4187-A392-6BA689651F80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {986253B2-863D-4187-A392-6BA689651F80}.Debug|Any CPU.Build.0 = Debug|Any CPU + {986253B2-863D-4187-A392-6BA689651F80}.Release|Any CPU.ActiveCfg = Release|Any CPU + {986253B2-863D-4187-A392-6BA689651F80}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {7800F762-7C35-40B0-A344-A7E2E57FBFE0} + EndGlobalSection +EndGlobal diff --git a/src/Agoda.GrapqhlGen/Agoda.GrapqhlGen.csproj b/src/Agoda.GrapqhlGen/Agoda.GrapqhlGen.csproj new file mode 100644 index 0000000..ceb1d0f --- /dev/null +++ b/src/Agoda.GrapqhlGen/Agoda.GrapqhlGen.csproj @@ -0,0 +1,25 @@ + + + + Exe + net8.0 + enable + enable + + + true + grapqhlgen + ./nupkg + 1.0.0 + Agoda.GrapqhlGen + Joel Dickson + Grapqhl Generation dotnet cli Tool + + + + + + + + + diff --git a/src/Agoda.GrapqhlGen/CodeGenerator.cs b/src/Agoda.GrapqhlGen/CodeGenerator.cs new file mode 100644 index 0000000..35541f2 --- /dev/null +++ b/src/Agoda.GrapqhlGen/CodeGenerator.cs @@ -0,0 +1,166 @@ +using System.Text; +using Microsoft.Extensions.Logging; + +namespace Agoda.GrapqhlGen; + +public class CodeGenerator +{ + private readonly string _schemaUrl; + private readonly string _inputPath; + private readonly string _outputPath; + private readonly string _baseNamespace; + private readonly Dictionary? _headers; + private readonly string _template; + private readonly string _modelFile; + private readonly ICommandExecutor _commandExecutor; + private readonly ILogger _logger; + + public CodeGenerator( + string schemaUrl, + string inputPath, + string outputPath, + string baseNamespace, + Dictionary? headers, + string template, + string modelFile, + ICommandExecutor? commandExecutor = null, + ILogger? logger = null) + { + _schemaUrl = schemaUrl; + _inputPath = inputPath; + _outputPath = outputPath; + _baseNamespace = baseNamespace; + _headers = headers; + _template = template; + _modelFile = modelFile; + _commandExecutor = commandExecutor ?? new CommandExecutor(); + _logger = logger ?? LoggerFactory.Create(builder => builder.AddConsole()) + .CreateLogger(); + } + + public async Task GenerateAsync() + { + _logger.LogInformation("Starting code generation..."); + + EnsureDirectoriesExist(); + CleanWorkingDirectory(); + CopyGraphQLFiles(); + await GenerateCode(); + ProcessRegions(); + CleanWorkingDirectory(); + + _logger.LogInformation("Code generation completed successfully."); + } + + private void EnsureDirectoriesExist() + { + _logger.LogDebug("Ensuring directory exists: {OutputPath}", _outputPath); + Directory.CreateDirectory(_outputPath); + } + + private void CleanWorkingDirectory() + { + _logger.LogDebug("Cleaning working directory..."); + foreach (var file in Directory.GetFiles(_outputPath, "*.graphql")) + { + _logger.LogDebug("Deleting GraphQL file: {File}", file); + File.Delete(file); + } + var classesFile = Path.Combine(_outputPath, "Classes.cs"); + if (File.Exists(classesFile)) + { + _logger.LogDebug("Deleting existing Classes.cs: {ClassesFile}", classesFile); + File.Delete(classesFile); + } + } + + private void CopyGraphQLFiles() + { + _logger.LogDebug("Copying GraphQL files from {InputPath} to {OutputPath}", _inputPath, _outputPath); + foreach (var file in Directory.GetFiles(_inputPath, "*.graphql", SearchOption.AllDirectories)) + { + var destFile = Path.Combine(_outputPath, Path.GetFileName(file)); + _logger.LogDebug("Copying {SourceFile} to {DestFile}", file, destFile); + File.Copy(file, destFile, true); + } + } + + private async Task GenerateCode() + { + _logger.LogInformation("Starting code generation process..."); + + // Ensure pnpm is installed + _logger.LogInformation("Installing pnpm..."); + await _commandExecutor.ExecuteAsync("npm", "install -g pnpm"); + + // Install dependencies using pnpm + _logger.LogInformation("Installing GraphQL CodeGen dependencies..."); + await _commandExecutor.ExecuteAsync("pnpm", "install @graphql-codegen/cli @graphql-codegen/typescript"); + + // Build pnpm command for code generation + var headerArgs = _headers?.Select(h => $"--header \"{h.Key}: {h.Value}\"") ?? Array.Empty(); + var args = $"graphql-codegen " + + $"--schema {_schemaUrl} " + + $"--template {_template} " + + $"--out {_outputPath} " + + string.Join(" ", headerArgs) + + $" {Path.Combine(_outputPath, "*.graphql")}"; + + _logger.LogDebug("Executing GraphQL CodeGen with args: {Arguments}", args); + await _commandExecutor.ExecuteAsync("pnpm", args); + + var classesFile = Path.Combine(_outputPath, "Classes.cs"); + _logger.LogDebug("Checking for Classes.cs at {ClassesFile}. Exists: {Exists}", + classesFile, File.Exists(classesFile)); + } + + private void ProcessRegions() + { + var classesFile = Path.Combine(_outputPath, "Classes.cs"); + _logger.LogDebug("Processing regions from {ClassesFile}", classesFile); + + if (!File.Exists(classesFile)) + { + _logger.LogError("Generated Classes.cs file not found at {ClassesFile}", classesFile); + throw new Exception($"Generated Classes.cs file not found at {classesFile}"); + } + + var regions = RegionParser.Parse(classesFile); + _logger.LogDebug("Found {RegionCount} regions", regions.Count()); + + // Process models + var models = regions.FirstOrDefault(r => r.Name == "input types"); + if (models != null) + { + var modelFile = Path.Combine(_outputPath, _modelFile); + _logger.LogInformation("Generating models file at {ModelFile}", modelFile); + GenerateFile(_modelFile, models); + } + else + { + _logger.LogWarning("No input types region found"); + } + + // Process other regions + foreach (var region in regions.Where(r => !new[] { "fragments", "input types", "Query" }.Contains(r.Name))) + { + var fileName = $"{region.Name}.generated.cs"; + _logger.LogInformation("Generating file for region {RegionName} at {FileName}", region.Name, fileName); + GenerateFile(fileName, region); + } + } + + private void GenerateFile(string fileName, Region region) + { + var filePath = Path.Combine(_outputPath, fileName); + _logger.LogDebug("Generating file at {FilePath}", filePath); + + var content = region.GetSourceCode() + .Replace("namespace Generated", $"\nnamespace {_baseNamespace}") + .Replace("using Agoda.CodeGen.GraphQL", "using Agoda.Graphql.Client") + .Replace("", ""); + + File.WriteAllText(filePath, content, Encoding.UTF8); + _logger.LogDebug("File generated successfully at {FilePath}", filePath); + } +} \ No newline at end of file diff --git a/src/Agoda.GrapqhlGen/CommandExecutor.cs b/src/Agoda.GrapqhlGen/CommandExecutor.cs new file mode 100644 index 0000000..cc51cbc --- /dev/null +++ b/src/Agoda.GrapqhlGen/CommandExecutor.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.Logging; + +namespace Agoda.GrapqhlGen; + +public interface ICommandExecutor +{ + Task ExecuteAsync(string command, string arguments); +} + +public class CommandExecutor : ICommandExecutor +{ + private readonly ILogger _logger; + + public CommandExecutor(ILogger? logger = null) + { + _logger = logger ?? LoggerFactory.Create(builder => builder.AddConsole()) + .CreateLogger(); + } + + public async Task ExecuteAsync(string command, string arguments) + { + var processStartInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = command, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + _logger.LogDebug("Executing command: {Command} {Arguments}", command, arguments); + + using var process = System.Diagnostics.Process.Start(processStartInfo); + if (process == null) + { + _logger.LogError("Failed to start process: {Command}", command); + throw new Exception($"Failed to start process: {command}"); + } + + var output = await process.StandardOutput.ReadToEndAsync(); + var error = await process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + + if (process.ExitCode != 0) + { + _logger.LogError("Command failed with exit code {ExitCode}. Error: {Error}", + process.ExitCode, error); + throw new Exception($"Command failed with exit code {process.ExitCode}. Error: {error}"); + } + + if (!string.IsNullOrWhiteSpace(output)) + { + _logger.LogDebug("Command output: {Output}", output); + } + } +} \ No newline at end of file diff --git a/src/Agoda.GrapqhlGen/Program.cs b/src/Agoda.GrapqhlGen/Program.cs new file mode 100644 index 0000000..75de3b6 --- /dev/null +++ b/src/Agoda.GrapqhlGen/Program.cs @@ -0,0 +1,131 @@ +using System.CommandLine; +using System.Text; +using Agoda.GrapqhlGen; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Console; + +namespace GraphqlCodeGen; + +public class Program +{ + public static async Task Main(string[] args) + { + var rootCommand = new RootCommand("GraphQL Code Generation Tool"); + + // Required options + var schemaUrlOption = new Option( + name: "--schema-url", + description: "URL of the GraphQL schema") + { IsRequired = true }; + + var inputPathOption = new Option( + name: "--input-path", + description: "Path to the directory containing .graphql files") + { IsRequired = true }; + + var outputPathOption = new Option( + name: "--output-path", + description: "Path where generated files will be saved") + { IsRequired = true }; + + // Optional options + var namespaceOption = new Option( + name: "--namespace", + description: "Base namespace for generated code", + getDefaultValue: () => "Generated"); + + var headersOption = new Option( + name: "--headers", + description: "Headers to include in the schema request (format: 'Key: Value')") + { AllowMultipleArgumentsPerToken = true }; + + var templateOption = new Option( + name: "--template", + description: "Template to use for code generation", + getDefaultValue: () => "typescript"); + + var modelFileOption = new Option( + name: "--model-file", + description: "Name of the generated models file", + getDefaultValue: () => "Models.cs"); + + var logLevelOption = new Option( + name: "--log-level", + description: "Set the logging level (Debug, Information, Warning, Error, Critical)", + getDefaultValue: () => LogLevel.Information); + + // Add options to command + rootCommand.AddOption(schemaUrlOption); + rootCommand.AddOption(inputPathOption); + rootCommand.AddOption(outputPathOption); + rootCommand.AddOption(namespaceOption); + rootCommand.AddOption(headersOption); + rootCommand.AddOption(templateOption); + rootCommand.AddOption(modelFileOption); + rootCommand.AddOption(logLevelOption); + + rootCommand.SetHandler(async ( + string schemaUrl, + string inputPath, + string outputPath, + string namespaceName, + string[] headers, + string template, + string modelFile, + LogLevel logLevel) => + { + try + { + var loggerFactory = LoggerFactory.Create(builder => + { + builder + .SetMinimumLevel(logLevel) + .AddConsole(options => + { + options.FormatterName = ConsoleFormatterNames.Simple; + }); + }); + + var logger = loggerFactory.CreateLogger(); + + var generator = new CodeGenerator( + schemaUrl, + inputPath, + outputPath, + namespaceName, + headers?.ToDictionary(h => h.Split(':')[0].Trim(), h => h.Split(':')[1].Trim()), + template, + modelFile, + commandExecutor: null, + logger: logger); + + await generator.GenerateAsync(); + } + catch (Exception ex) + { + using var loggerFactory = LoggerFactory.Create(builder => + { + builder + .SetMinimumLevel(LogLevel.Error) + .AddConsole(options => + { + options.FormatterName = ConsoleFormatterNames.Simple; + }); + }); + var logger = loggerFactory.CreateLogger(); + logger.LogError(ex, "Error during code generation"); + Environment.Exit(1); + } + }, + schemaUrlOption, + inputPathOption, + outputPathOption, + namespaceOption, + headersOption, + templateOption, + modelFileOption, + logLevelOption); + + return await rootCommand.InvokeAsync(args); + } +} \ No newline at end of file diff --git a/src/Agoda.GrapqhlGen/Region.cs b/src/Agoda.GrapqhlGen/Region.cs new file mode 100644 index 0000000..eab05c7 --- /dev/null +++ b/src/Agoda.GrapqhlGen/Region.cs @@ -0,0 +1,7 @@ +namespace Agoda.GrapqhlGen; + +public record Region(string Name, List Lines) +{ + public string GetSourceCode() => + string.Join(Environment.NewLine, Lines.Where(l => !string.IsNullOrWhiteSpace(l))); +} diff --git a/src/Agoda.GrapqhlGen/RegionParser.cs b/src/Agoda.GrapqhlGen/RegionParser.cs new file mode 100644 index 0000000..931aa7f --- /dev/null +++ b/src/Agoda.GrapqhlGen/RegionParser.cs @@ -0,0 +1,78 @@ +using Microsoft.Extensions.Logging; + +namespace Agoda.GrapqhlGen; + +public static class RegionParser +{ + public static IEnumerable Parse(string filePath, ILogger? logger = null) + { + logger ??= LoggerFactory.Create(builder => builder.AddConsole()) + .CreateLogger(typeof(RegionParser).FullName!); + + if (!File.Exists(filePath)) + { + logger.LogError("File does not exist: {FilePath}", filePath); + return Enumerable.Empty(); + } + + var fileContent = File.ReadAllText(filePath); + logger.LogDebug("File content before processing:{NewLine}{FileContent}", + Environment.NewLine, fileContent); + + var regions = new List(); + var lines = File.ReadAllLines(filePath); + var currentRegion = ""; + var currentLines = new List(); + var imports = new List(); + var inRegion = false; + + foreach (var line in lines) + { + logger.LogTrace("Processing line: '{Line}'", line); + + if (line.TrimStart().StartsWith("#region")) + { + inRegion = true; + currentRegion = line.TrimStart().Replace("#region", "").Trim(); + logger.LogDebug("Starting region: '{RegionName}'", currentRegion); + continue; + } + + if (line.TrimStart().StartsWith("#endregion")) + { + if (inRegion) + { + logger.LogDebug("Ending region: '{RegionName}' with {LineCount} lines", + currentRegion, currentLines.Count); + regions.Add(new Region(currentRegion, imports.Concat(currentLines).ToList())); + currentLines = new List(); + inRegion = false; + } + continue; + } + + if (!inRegion) + { + imports.Add(line); + } + else + { + currentLines.Add(line); + } + } + + logger.LogInformation("Found {RegionCount} regions", regions.Count); + + foreach (var region in regions) + { + logger.LogDebug( + "Region: '{RegionName}' with {LineCount} lines{NewLine}Content:{NewLine}{Content}", + region.Name, + region.Lines.Count, + Environment.NewLine, + region.GetSourceCode()); + } + + return regions; + } +} \ No newline at end of file