Repository: microsoft/kiota (csharp)

Issue:
provide a none key for serializers and deserializers so no registration is done in the client
Hi there 👋  ,

I'm finally taking the time to roll out my own versions of Http Client and Json serialization for Java.
While connecting all of the dots toghether, I have the impression that the generated code is not completely portable in regards to serialization libraries.

This is the use case:

- I have an alternative Core library for Json ser/deser based on Jackson
- In the Maven plugin and Quarkus extension I integrate the `kiota` CLI and I tune(using CLI options) the serializers/deserializers to default to the Jackson implementation
- A user(of those integrations) generates an SDK using my tooling (it will depend on Jackson deser) and publish it
- An end user of the generated SDK wanna use, as well, the Graph API SDK

To my understanding, the end user would need to provide on the classpath both of the serialization libraries (the default on Gson and the alternative on Jackson), and, depending  on the specific client he will use a different ser/deser library, which, I believe, is undesirable.

In an ideal world, the end user should be able to pull in the two generated SDKs and have on the classpath only one version of the serializers/deserializers libraries that he should be able to change.

Currently, the dependency is checked by the compiler, so, even if there is a way(through the `RequestAdapter`?) to actually use only one of the implementations they need to be available on the classpath to succeed the compilation step.

In this situation, the only possibility for the SDK author is to start publishing for the matrix of the possible ser/deser alternatives, again this is a viable workaround but it will cause friction and, possibly, fracturate the ecosystem.

I understand that the final solution for such situations(we all aim for) is to let any end user re-generate in their end project all of the needed clients, sadly, (I think) we agree that this is not viable as of today.

## Proposal

I have just skimmed through, but this doesn't seems to be an "intrinsic" problem of the tool, but just a limitation of the current implementation.
The ser/deser are registered only(at best of my understanding) in the `BaseRequestBuilder` generated class that gets instantiated passing as argument the `RequestAdapter` where ser/deser can be configured programmatically by the end user.

Doing a little bit of "inversion of control" should solve the situation, e.g. doing the ser/deser registration directly in the `RequestAdapter` instead of hardcoding it in the generated request builder.

Currently we have the generated API class constructor:

```java
    public ApiClient(@jakarta.annotation.Nonnull final RequestAdapter requestAdapter) {
        super(requestAdapter, "{+baseurl}");
        this.pathParameters = new HashMap<>();
        ApiClientBuilder.registerDefaultSerializer(JsonSerializationWriterFactory.class);
        ...
        if (requestAdapter.getBaseUrl() == null || requestAdapter.getBaseUrl().isEmpty()) {
            requestAdapter.setBaseUrl("https://api.twilio.com");
        }
        pathParameters.put("baseurl", requestAdapter.getBaseUrl());
    }
```

that can, possibly become something like:

```java
    public ApiClient(@jakarta.annotation.Nonnull final RequestAdapter requestAdapter) {
        super(requestAdapter, "{+baseurl}");
        this.pathParameters = new HashMap<>();
        requestAdapter.registerDefaultSerializers();
        if (requestAdapter.getBaseUrl() == null || requestAdapter.getBaseUrl().isEmpty()) {
            requestAdapter.setBaseUrl("https://api.twilio.com");
        }
        pathParameters.put("baseurl", requestAdapter.getBaseUrl());
    }
```

This moves the injection point to the `RequestAdapter` layer that ultimately becomes the single source of truth for ser/deser and  is defined in the end user application.
If I have not missed any invariant we should be able to completely remove `--serializer` and `--deserializer` from the CLI options, turning this in a breaking change.
Thoughts?

Pull request:
fixes #3796 cc @andreaTP

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b746b9b1fb..7955a9f104 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Added
 
+- Added 'none' key for serializer and deserializer arguments to enable portable clients generation. [#3796](https://github.com/microsoft/kiota/issues/3796)
 - Added Japanese translations to vscode extension.
 - Added support for deprecation annotations in Python. [#2798](https://github.com/microsoft/kiota/issues/2798)
 
diff --git a/src/Kiota.Builder/KiotaBuilder.cs b/src/Kiota.Builder/KiotaBuilder.cs
index bd618c9e94..cc032871ef 100644
--- a/src/Kiota.Builder/KiotaBuilder.cs
+++ b/src/Kiota.Builder/KiotaBuilder.cs
@@ -941,8 +941,8 @@ private void CreateUrlManagement(CodeClass currentClass, OpenApiUrlTreeNode curr
         currentClass.AddProperty(pathParametersProperty);
         if (isApiClientClass)
         {
-            constructor.SerializerModules = config.Serializers;
-            constructor.DeserializerModules = config.Deserializers;
+            constructor.SerializerModules = ReplaceNoneSerializersByEmptySet(config.Serializers);
+            constructor.DeserializerModules = ReplaceNoneSerializersByEmptySet(config.Deserializers);
             constructor.BaseUrl = config.ApiRootUrl ?? string.Empty;
             pathParametersProperty.DefaultValue = $"new {pathParametersProperty.Type.Name}()";
         }
@@ -1007,6 +1007,12 @@ private void CreateUrlManagement(CodeClass currentClass, OpenApiUrlTreeNode curr
             currentClass.AddMethod(overloadCtor);
         }
     }
+    private static HashSet<string> ReplaceNoneSerializersByEmptySet(HashSet<string> serializers)
+    {
+        if (serializers.Count == 1 && serializers.Contains("none")) return [];
+        return serializers;
+    }
+
     private static readonly Func<CodeClass, int> shortestNamespaceOrder = x => x.GetNamespaceDepth();
     /// <summary>
     /// Remaps definitions to custom types so they can be used later in generation or in refiners
diff --git a/src/kiota/KiotaHost.cs b/src/kiota/KiotaHost.cs
index d62b428369..473a563fde 100644
--- a/src/kiota/KiotaHost.cs
+++ b/src/kiota/KiotaHost.cs
@@ -355,15 +355,15 @@ private static Command GetGenerateCommand()
 
         var serializerOption = new Option<List<string>>(
             "--serializer",
-            () => defaultConfiguration.Serializers.ToList(),
-            "The fully qualified class names for serializers. Accepts multiple values.");
+            () => [.. defaultConfiguration.Serializers],
+            "The fully qualified class names for serializers. Accepts multiple values. Use `none` to generate a client without any serializer.");
         serializerOption.AddAlias("-s");
         serializerOption.ArgumentHelpName = "classes";
 
         var deserializerOption = new Option<List<string>>(
             "--deserializer",
-            () => defaultConfiguration.Deserializers.ToList(),
-            "The fully qualified class names for deserializers. Accepts multiple values.");
+            () => [.. defaultConfiguration.Deserializers],
+            "The fully qualified class names for deserializers. Accepts multiple values. Use `none` to generate a client without any deserializer.");
         deserializerOption.AddAlias("--ds");
         deserializerOption.ArgumentHelpName = "classes";
 
@@ -371,7 +371,7 @@ private static Command GetGenerateCommand()
 
         var structuredMimeTypesOption = new Option<List<string>>(
             "--structured-mime-types",
-            () => defaultConfiguration.StructuredMimeTypes.ToList(),
+            () => [.. defaultConfiguration.StructuredMimeTypes],
         "The MIME types with optional priorities as defined in RFC9110 Accept header to use for structured data model generation. Accepts multiple values.");
         structuredMimeTypesOption.AddAlias("-m");
 


Test patch:
diff --git a/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs b/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs
index 8521c78141..228f8886d7 100644
--- a/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs
+++ b/tests/Kiota.Builder.Tests/KiotaBuilderTests.cs
@@ -73,6 +73,42 @@ await File.WriteAllTextAsync(tempFilePath, @$"openapi: 3.0.1
         Assert.Equal(expected, constructor.BaseUrl);
     }
     [Fact]
+    public async Task HonoursNoneKeyForSerialization()
+    {
+        var tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetTempFileName());
+        await File.WriteAllTextAsync(tempFilePath, @$"openapi: 3.0.1
+info:
+  title: OData Service for namespace microsoft.graph
+  description: This OData service is located at https://graph.microsoft.com/v1.0
+  version: 1.0.1
+servers:
+  - url: https://graph.microsoft.com/v1.0
+paths:
+  /enumeration:
+    get:
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                type: string");
+        var mockLogger = new Mock<ILogger<KiotaBuilder>>();
+        var builder = new KiotaBuilder(mockLogger.Object, new GenerationConfiguration { ClientClassName = "Graph", OpenAPIFilePath = "https://graph.microsoft.com/description.yaml", Serializers = ["none"], Deserializers = ["none"] }, _httpClient);
+        await using var fs = new FileStream(tempFilePath, FileMode.Open);
+        var document = await builder.CreateOpenApiDocumentAsync(fs);
+        var node = builder.CreateUriSpace(document);
+        builder.SetApiRootUrl();
+        var codeModel = builder.CreateSourceModel(node);
+        var rootNS = codeModel.FindNamespaceByName("ApiSdk");
+        Assert.NotNull(rootNS);
+        var clientBuilder = rootNS.FindChildByName<CodeClass>("Graph", false);
+        Assert.NotNull(clientBuilder);
+        var constructor = clientBuilder.Methods.FirstOrDefault(static x => x.IsOfKind(CodeMethodKind.ClientConstructor));
+        Assert.NotNull(constructor);
+        Assert.Empty(constructor.SerializerModules);
+        Assert.Empty(constructor.DeserializerModules);
+    }
+    [Fact]
     public async Task DeduplicatesHostNames()
     {
         var tempFilePath = Path.Combine(Path.GetTempPath(), Path.GetTempFileName());