From fa2e6a390cf1c1bb81014fdd32e20559dcc80f1d Mon Sep 17 00:00:00 2001 From: Alex McKinney Date: Wed, 26 Feb 2025 17:43:35 -0500 Subject: [PATCH] feat(cli): Add support for object property 'access' field (#6197) --- fern.schema.json | 30 +- .../apis/fern-definition/definition/types.yml | 19 +- package-yml.schema.json | 30 +- packages/cli/cli/versions.yml | 6 + .../csharp-property-access/type__User.json | 25 + .../types/types/ObjectPropertyAccess.ts | 9 + .../types/types/ObjectPropertySchema.ts | 2 +- .../types/ObjectPropertyWithAccessSchema.ts | 9 + .../api/resources/types/types/index.ts | 2 + .../types/types/ObjectPropertyAccess.ts | 16 + .../types/types/ObjectPropertySchema.ts | 6 +- .../types/ObjectPropertyWithAccessSchema.ts | 24 + .../resources/types/types/index.ts | 2 + .../src/ast/visitors/visitTypeDeclarations.ts | 3 +- .../src/ast/visitors/visitWebhooks.ts | 3 +- .../csharp-property-access.json | 200 +++++ ...onvertDiscriminatedUnionTypeDeclaration.ts | 6 +- .../convertObjectTypeDeclaration.ts | 29 +- .../__snapshots__/csharp-property-access.json | 147 ++++ .../lazy-fern-workspace/src/fern.schema.json | 30 +- .../src/package-yml.schema.json | 30 +- .../.github/workflows/ci.yml | 69 ++ .../csharp-property-access/.gitignore | 484 ++++++++++++ .../.mock/definition/__package__.yml | 21 + .../.mock/definition/api.yml | 1 + .../.mock/fern.config.json | 1 + .../.mock/generators.yml | 1 + .../snippet-templates.json | 0 .../csharp-property-access/snippet.json | 0 .../Core/Json/DateOnlyJsonTests.cs | 76 ++ .../Core/Json/DateTimeJsonTests.cs | 110 +++ .../Core/Json/EnumSerializerTests.cs | 60 ++ .../Core/Json/OneOfSerializerTests.cs | 311 ++++++++ .../SeedCsharpAccess.Test.Custom.props | 7 + .../SeedCsharpAccess.Test.csproj | 38 + .../Core/CollectionItemSerializer.cs | 91 +++ .../src/SeedCsharpAccess/Core/Constants.cs | 7 + .../Core/DateOnlyConverter.cs | 747 ++++++++++++++++++ .../Core/DateTimeSerializer.cs | 22 + .../SeedCsharpAccess/Core/EnumSerializer.cs | 53 ++ .../Core/JsonConfiguration.cs | 49 ++ .../SeedCsharpAccess/Core/OneOfSerializer.cs | 91 +++ .../SeedCsharpAccess/Core/Public/Version.cs | 6 + .../SeedCsharpAccess.Custom.props | 20 + .../SeedCsharpAccess/SeedCsharpAccess.csproj | 53 ++ .../src/SeedCsharpAccess/User.cs | 24 + .../.github/workflows/ci.yml | 69 ++ .../csharp-property-access/.gitignore | 484 ++++++++++++ .../.mock/definition/__package__.yml | 21 + .../.mock/definition/api.yml | 1 + .../.mock/fern.config.json | 1 + .../.mock/generators.yml | 1 + .../csharp-property-access/README.md | 95 +++ .../csharp-property-access/reference.md | 48 ++ .../snippet-templates.json | 0 .../csharp-property-access/snippet.json | 17 + .../Core/Json/DateOnlyJsonTests.cs | 76 ++ .../Core/Json/DateTimeJsonTests.cs | 110 +++ .../Core/Json/EnumSerializerTests.cs | 60 ++ .../Core/Json/OneOfSerializerTests.cs | 311 ++++++++ .../Core/RawClientTests.cs | 109 +++ .../SeedCsharpAccess.Test.Custom.props | 7 + .../SeedCsharpAccess.Test.csproj | 38 + .../src/SeedCsharpAccess.Test/TestClient.cs | 6 + .../Unit/MockServer/BaseMockServerTest.cs | 38 + .../Unit/MockServer/CreateUserTest.cs | 64 ++ .../Core/CollectionItemSerializer.cs | 91 +++ .../src/SeedCsharpAccess/Core/Constants.cs | 7 + .../Core/DateOnlyConverter.cs | 747 ++++++++++++++++++ .../Core/DateTimeSerializer.cs | 22 + .../SeedCsharpAccess/Core/EnumSerializer.cs | 53 ++ .../src/SeedCsharpAccess/Core/Extensions.cs | 14 + .../src/SeedCsharpAccess/Core/HeaderValue.cs | 17 + .../src/SeedCsharpAccess/Core/Headers.cs | 17 + .../Core/HttpMethodExtensions.cs | 8 + .../SeedCsharpAccess/Core/IRequestOptions.cs | 32 + .../Core/JsonConfiguration.cs | 49 ++ .../SeedCsharpAccess/Core/OneOfSerializer.cs | 91 +++ .../Core/Public/ClientOptions.cs | 48 ++ .../Core/Public/RequestOptions.cs | 33 + .../Public/SeedCsharpAccessApiException.cs | 18 + .../Core/Public/SeedCsharpAccessException.cs | 9 + .../SeedCsharpAccess/Core/Public/Version.cs | 6 + .../src/SeedCsharpAccess/Core/RawClient.cs | 196 +++++ .../SeedCsharpAccess.Custom.props | 20 + .../SeedCsharpAccess/SeedCsharpAccess.csproj | 53 ++ .../SeedCsharpAccessClient.cs | 85 ++ .../src/SeedCsharpAccess/Types/User.cs | 24 + .../definition/__package__.yml | 21 + .../csharp-property-access/definition/api.yml | 1 + .../csharp-property-access/generators.yml | 1 + 91 files changed, 6142 insertions(+), 47 deletions(-) create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/csharp-property-access/type__User.json create mode 100644 packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/ObjectPropertyAccess.ts create mode 100644 packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/ObjectPropertyWithAccessSchema.ts create mode 100644 packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/ObjectPropertyAccess.ts create mode 100644 packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/ObjectPropertyWithAccessSchema.ts create mode 100644 packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions/csharp-property-access.json create mode 100644 packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/csharp-property-access.json create mode 100644 seed/csharp-model/csharp-property-access/.github/workflows/ci.yml create mode 100644 seed/csharp-model/csharp-property-access/.gitignore create mode 100644 seed/csharp-model/csharp-property-access/.mock/definition/__package__.yml create mode 100644 seed/csharp-model/csharp-property-access/.mock/definition/api.yml create mode 100644 seed/csharp-model/csharp-property-access/.mock/fern.config.json create mode 100644 seed/csharp-model/csharp-property-access/.mock/generators.yml create mode 100644 seed/csharp-model/csharp-property-access/snippet-templates.json create mode 100644 seed/csharp-model/csharp-property-access/snippet.json create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateOnlyJsonTests.cs create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateTimeJsonTests.cs create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/EnumSerializerTests.cs create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/OneOfSerializerTests.cs create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.Custom.props create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.csproj create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/CollectionItemSerializer.cs create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/Constants.cs create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/DateOnlyConverter.cs create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/DateTimeSerializer.cs create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/EnumSerializer.cs create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/JsonConfiguration.cs create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/OneOfSerializer.cs create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/Public/Version.cs create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.Custom.props create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.csproj create mode 100644 seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/User.cs create mode 100644 seed/csharp-sdk/csharp-property-access/.github/workflows/ci.yml create mode 100644 seed/csharp-sdk/csharp-property-access/.gitignore create mode 100644 seed/csharp-sdk/csharp-property-access/.mock/definition/__package__.yml create mode 100644 seed/csharp-sdk/csharp-property-access/.mock/definition/api.yml create mode 100644 seed/csharp-sdk/csharp-property-access/.mock/fern.config.json create mode 100644 seed/csharp-sdk/csharp-property-access/.mock/generators.yml create mode 100644 seed/csharp-sdk/csharp-property-access/README.md create mode 100644 seed/csharp-sdk/csharp-property-access/reference.md create mode 100644 seed/csharp-sdk/csharp-property-access/snippet-templates.json create mode 100644 seed/csharp-sdk/csharp-property-access/snippet.json create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateOnlyJsonTests.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateTimeJsonTests.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/EnumSerializerTests.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/OneOfSerializerTests.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/RawClientTests.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.Custom.props create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.csproj create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/TestClient.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Unit/MockServer/BaseMockServerTest.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Unit/MockServer/CreateUserTest.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/CollectionItemSerializer.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Constants.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/DateOnlyConverter.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/DateTimeSerializer.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/EnumSerializer.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Extensions.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/HeaderValue.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Headers.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/HttpMethodExtensions.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/IRequestOptions.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/JsonConfiguration.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/OneOfSerializer.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/ClientOptions.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/RequestOptions.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/SeedCsharpAccessApiException.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/SeedCsharpAccessException.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/Version.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/RawClient.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.Custom.props create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.csproj create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccessClient.cs create mode 100644 seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Types/User.cs create mode 100644 test-definitions/fern/apis/csharp-property-access/definition/__package__.yml create mode 100644 test-definitions/fern/apis/csharp-property-access/definition/api.yml create mode 100644 test-definitions/fern/apis/csharp-property-access/generators.yml diff --git a/fern.schema.json b/fern.schema.json index 58d12d76a1c..6278e6169cf 100644 --- a/fern.schema.json +++ b/fern.schema.json @@ -356,9 +356,19 @@ } ] }, - "types.TypeReferenceDeclarationWithName": { + "types.ObjectPropertyAccess": { + "type": "string", + "enum": [ + "read-only", + "write-only" + ] + }, + "types.ObjectPropertyWithAccessSchema": { "type": "object", "properties": { + "type": { + "type": "string" + }, "default": { "oneOf": [ { @@ -439,8 +449,15 @@ } ] }, - "type": { - "type": "string" + "access": { + "oneOf": [ + { + "$ref": "#/definitions/types.ObjectPropertyAccess" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -448,19 +465,16 @@ ], "additionalProperties": false }, - "types.TypeReferenceDeclarationWithNameSchema": { + "types.ObjectPropertySchema": { "anyOf": [ { "type": "string" }, { - "$ref": "#/definitions/types.TypeReferenceDeclarationWithName" + "$ref": "#/definitions/types.ObjectPropertyWithAccessSchema" } ] }, - "types.ObjectPropertySchema": { - "$ref": "#/definitions/types.TypeReferenceDeclarationWithNameSchema" - }, "types.ObjectSchema": { "type": "object", "properties": { diff --git a/fern/apis/fern-definition/definition/types.yml b/fern/apis/fern-definition/definition/types.yml index 24b5ad229ad..8d049c14bf7 100644 --- a/fern/apis/fern-definition/definition/types.yml +++ b/fern/apis/fern-definition/definition/types.yml @@ -109,7 +109,24 @@ types: properties: optional> extra-properties: optional - ObjectPropertySchema: TypeReferenceDeclarationWithNameSchema + ObjectPropertySchema: + discriminated: false + union: + - string + - ObjectPropertyWithAccessSchema + + ObjectPropertyWithAccessSchema: + extends: + - TypeReferenceDeclarationWithName + properties: + access: optional + + ObjectPropertyAccess: + enum: + - value: read-only + name: ReadOnly + - value: write-only + name: WriteOnly ObjectExtendsSchema: discriminated: false diff --git a/package-yml.schema.json b/package-yml.schema.json index 5a99425c680..aecba620717 100644 --- a/package-yml.schema.json +++ b/package-yml.schema.json @@ -376,9 +376,19 @@ } ] }, - "types.TypeReferenceDeclarationWithName": { + "types.ObjectPropertyAccess": { + "type": "string", + "enum": [ + "read-only", + "write-only" + ] + }, + "types.ObjectPropertyWithAccessSchema": { "type": "object", "properties": { + "type": { + "type": "string" + }, "default": { "oneOf": [ { @@ -459,8 +469,15 @@ } ] }, - "type": { - "type": "string" + "access": { + "oneOf": [ + { + "$ref": "#/definitions/types.ObjectPropertyAccess" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -468,19 +485,16 @@ ], "additionalProperties": false }, - "types.TypeReferenceDeclarationWithNameSchema": { + "types.ObjectPropertySchema": { "anyOf": [ { "type": "string" }, { - "$ref": "#/definitions/types.TypeReferenceDeclarationWithName" + "$ref": "#/definitions/types.ObjectPropertyWithAccessSchema" } ] }, - "types.ObjectPropertySchema": { - "$ref": "#/definitions/types.TypeReferenceDeclarationWithNameSchema" - }, "types.ObjectSchema": { "type": "object", "properties": { diff --git a/packages/cli/cli/versions.yml b/packages/cli/cli/versions.yml index 5de5c3b771a..b3c2445defa 100644 --- a/packages/cli/cli/versions.yml +++ b/packages/cli/cli/versions.yml @@ -1,3 +1,9 @@ +- changelogEntry: + - summary: The Fern definition now supports specifying object properties as `read-only` or `write-only`. + type: feat + irVersion: 55 + version: 0.55.0 + - changelogEntry: - summary: Add support for the `x-fern-enum` extension in the AsyncAPI v3 parser. type: feat diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/csharp-property-access/type__User.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/csharp-property-access/type__User.json new file mode 100644 index 00000000000..1f44fdb36c2 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/csharp-property-access/type__User.json @@ -0,0 +1,25 @@ +{ + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "password": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "email", + "password" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/ObjectPropertyAccess.ts b/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/ObjectPropertyAccess.ts new file mode 100644 index 00000000000..1bd47b3cd01 --- /dev/null +++ b/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/ObjectPropertyAccess.ts @@ -0,0 +1,9 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +export type ObjectPropertyAccess = "read-only" | "write-only"; +export const ObjectPropertyAccess = { + ReadOnly: "read-only", + WriteOnly: "write-only", +} as const; diff --git a/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/ObjectPropertySchema.ts b/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/ObjectPropertySchema.ts index a00640c19d7..2e6dbd952dc 100644 --- a/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/ObjectPropertySchema.ts +++ b/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/ObjectPropertySchema.ts @@ -4,4 +4,4 @@ import * as FernDefinition from "../../../index"; -export type ObjectPropertySchema = FernDefinition.TypeReferenceDeclarationWithNameSchema; +export type ObjectPropertySchema = string | FernDefinition.ObjectPropertyWithAccessSchema; diff --git a/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/ObjectPropertyWithAccessSchema.ts b/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/ObjectPropertyWithAccessSchema.ts new file mode 100644 index 00000000000..349e6f79c28 --- /dev/null +++ b/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/ObjectPropertyWithAccessSchema.ts @@ -0,0 +1,9 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as FernDefinition from "../../../index"; + +export interface ObjectPropertyWithAccessSchema extends FernDefinition.TypeReferenceDeclarationWithName { + "access"?: FernDefinition.ObjectPropertyAccess; +} diff --git a/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/index.ts b/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/index.ts index 30dd0907b8b..f6f0f265be4 100644 --- a/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/index.ts +++ b/packages/cli/fern-definition/schema/src/schemas/api/resources/types/types/index.ts @@ -8,6 +8,8 @@ export * from "./BaseTypeDeclarationSchema"; export * from "./AliasSchema"; export * from "./ObjectSchema"; export * from "./ObjectPropertySchema"; +export * from "./ObjectPropertyWithAccessSchema"; +export * from "./ObjectPropertyAccess"; export * from "./ObjectExtendsSchema"; export * from "./EnumSchema"; export * from "./EnumValue"; diff --git a/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/ObjectPropertyAccess.ts b/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/ObjectPropertyAccess.ts new file mode 100644 index 00000000000..6f1ada56d16 --- /dev/null +++ b/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/ObjectPropertyAccess.ts @@ -0,0 +1,16 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../../../index"; +import * as FernDefinition from "../../../../api/index"; +import * as core from "../../../../core"; + +export const ObjectPropertyAccess: core.serialization.Schema< + serializers.ObjectPropertyAccess.Raw, + FernDefinition.ObjectPropertyAccess +> = core.serialization.enum_(["read-only", "write-only"]); + +export declare namespace ObjectPropertyAccess { + export type Raw = "read-only" | "write-only"; +} diff --git a/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/ObjectPropertySchema.ts b/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/ObjectPropertySchema.ts index f6134e8081c..56616b49aa8 100644 --- a/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/ObjectPropertySchema.ts +++ b/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/ObjectPropertySchema.ts @@ -5,13 +5,13 @@ import * as serializers from "../../../index"; import * as FernDefinition from "../../../../api/index"; import * as core from "../../../../core"; -import { TypeReferenceDeclarationWithNameSchema } from "./TypeReferenceDeclarationWithNameSchema"; +import { ObjectPropertyWithAccessSchema } from "./ObjectPropertyWithAccessSchema"; export const ObjectPropertySchema: core.serialization.Schema< serializers.ObjectPropertySchema.Raw, FernDefinition.ObjectPropertySchema -> = TypeReferenceDeclarationWithNameSchema; +> = core.serialization.undiscriminatedUnion([core.serialization.string(), ObjectPropertyWithAccessSchema]); export declare namespace ObjectPropertySchema { - export type Raw = TypeReferenceDeclarationWithNameSchema.Raw; + export type Raw = string | ObjectPropertyWithAccessSchema.Raw; } diff --git a/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/ObjectPropertyWithAccessSchema.ts b/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/ObjectPropertyWithAccessSchema.ts new file mode 100644 index 00000000000..8c126ee4e38 --- /dev/null +++ b/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/ObjectPropertyWithAccessSchema.ts @@ -0,0 +1,24 @@ +/** + * This file was auto-generated by Fern from our API Definition. + */ + +import * as serializers from "../../../index"; +import * as FernDefinition from "../../../../api/index"; +import * as core from "../../../../core"; +import { ObjectPropertyAccess } from "./ObjectPropertyAccess"; +import { TypeReferenceDeclarationWithName } from "./TypeReferenceDeclarationWithName"; + +export const ObjectPropertyWithAccessSchema: core.serialization.ObjectSchema< + serializers.ObjectPropertyWithAccessSchema.Raw, + FernDefinition.ObjectPropertyWithAccessSchema +> = core.serialization + .object({ + "access": ObjectPropertyAccess.optional(), + }) + .extend(TypeReferenceDeclarationWithName); + +export declare namespace ObjectPropertyWithAccessSchema { + export interface Raw extends TypeReferenceDeclarationWithName.Raw { + "access"?: ObjectPropertyAccess.Raw | null; + } +} diff --git a/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/index.ts b/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/index.ts index 30dd0907b8b..f6f0f265be4 100644 --- a/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/index.ts +++ b/packages/cli/fern-definition/schema/src/schemas/serialization/resources/types/types/index.ts @@ -8,6 +8,8 @@ export * from "./BaseTypeDeclarationSchema"; export * from "./AliasSchema"; export * from "./ObjectSchema"; export * from "./ObjectPropertySchema"; +export * from "./ObjectPropertyWithAccessSchema"; +export * from "./ObjectPropertyAccess"; export * from "./ObjectExtendsSchema"; export * from "./EnumSchema"; export * from "./EnumValue"; diff --git a/packages/cli/fern-definition/validator/src/ast/visitors/visitTypeDeclarations.ts b/packages/cli/fern-definition/validator/src/ast/visitors/visitTypeDeclarations.ts index af82f65e476..ae4e1aa848d 100644 --- a/packages/cli/fern-definition/validator/src/ast/visitors/visitTypeDeclarations.ts +++ b/packages/cli/fern-definition/validator/src/ast/visitors/visitTypeDeclarations.ts @@ -114,7 +114,8 @@ export function visitTypeDeclaration({ audiences: noop, encoding: noop, default: noop, - validation: noop + validation: noop, + access: noop }); } } diff --git a/packages/cli/fern-definition/validator/src/ast/visitors/visitWebhooks.ts b/packages/cli/fern-definition/validator/src/ast/visitors/visitWebhooks.ts index 6d922334240..f2b7a82de18 100644 --- a/packages/cli/fern-definition/validator/src/ast/visitors/visitWebhooks.ts +++ b/packages/cli/fern-definition/validator/src/ast/visitors/visitWebhooks.ts @@ -86,7 +86,8 @@ export function visitWebhooks({ audiences: noop, encoding: noop, default: noop, - validation: noop + validation: noop, + access: noop }); } } diff --git a/packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions/csharp-property-access.json b/packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions/csharp-property-access.json new file mode 100644 index 00000000000..9918cb84af8 --- /dev/null +++ b/packages/cli/generation/ir-generator-tests/src/dynamic-snippets/__test__/test-definitions/csharp-property-access.json @@ -0,0 +1,200 @@ +{ + "version": "1.0.0", + "types": { + "type_:User": { + "type": "object", + "declaration": { + "name": { + "originalName": "User", + "camelCase": { + "unsafeName": "user", + "safeName": "user" + }, + "snakeCase": { + "unsafeName": "user", + "safeName": "user" + }, + "screamingSnakeCase": { + "unsafeName": "USER", + "safeName": "USER" + }, + "pascalCase": { + "unsafeName": "User", + "safeName": "User" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + } + }, + "properties": [ + { + "name": { + "name": { + "originalName": "id", + "camelCase": { + "unsafeName": "id", + "safeName": "id" + }, + "snakeCase": { + "unsafeName": "id", + "safeName": "id" + }, + "screamingSnakeCase": { + "unsafeName": "ID", + "safeName": "ID" + }, + "pascalCase": { + "unsafeName": "ID", + "safeName": "ID" + } + }, + "wireValue": "id" + }, + "typeReference": { + "type": "primitive", + "value": "STRING" + } + }, + { + "name": { + "name": { + "originalName": "name", + "camelCase": { + "unsafeName": "name", + "safeName": "name" + }, + "snakeCase": { + "unsafeName": "name", + "safeName": "name" + }, + "screamingSnakeCase": { + "unsafeName": "NAME", + "safeName": "NAME" + }, + "pascalCase": { + "unsafeName": "Name", + "safeName": "Name" + } + }, + "wireValue": "name" + }, + "typeReference": { + "type": "primitive", + "value": "STRING" + } + }, + { + "name": { + "name": { + "originalName": "email", + "camelCase": { + "unsafeName": "email", + "safeName": "email" + }, + "snakeCase": { + "unsafeName": "email", + "safeName": "email" + }, + "screamingSnakeCase": { + "unsafeName": "EMAIL", + "safeName": "EMAIL" + }, + "pascalCase": { + "unsafeName": "Email", + "safeName": "Email" + } + }, + "wireValue": "email" + }, + "typeReference": { + "type": "primitive", + "value": "STRING" + } + }, + { + "name": { + "name": { + "originalName": "password", + "camelCase": { + "unsafeName": "password", + "safeName": "password" + }, + "snakeCase": { + "unsafeName": "password", + "safeName": "password" + }, + "screamingSnakeCase": { + "unsafeName": "PASSWORD", + "safeName": "PASSWORD" + }, + "pascalCase": { + "unsafeName": "Password", + "safeName": "Password" + } + }, + "wireValue": "password" + }, + "typeReference": { + "type": "primitive", + "value": "STRING" + } + } + ] + } + }, + "headers": [], + "endpoints": { + "endpoint_.createUser": { + "auth": null, + "declaration": { + "name": { + "originalName": "createUser", + "camelCase": { + "unsafeName": "createUser", + "safeName": "createUser" + }, + "snakeCase": { + "unsafeName": "create_user", + "safeName": "create_user" + }, + "screamingSnakeCase": { + "unsafeName": "CREATE_USER", + "safeName": "CREATE_USER" + }, + "pascalCase": { + "unsafeName": "CreateUser", + "safeName": "CreateUser" + } + }, + "fernFilepath": { + "allParts": [], + "packagePath": [], + "file": null + } + }, + "location": { + "method": "POST", + "path": "/users" + }, + "request": { + "type": "body", + "pathParameters": [], + "body": { + "type": "typeReference", + "value": { + "type": "named", + "value": "type_:User" + } + } + }, + "response": { + "type": "json" + }, + "examples": null + } + }, + "environments": null +} \ No newline at end of file diff --git a/packages/cli/generation/ir-generator/src/converters/type-declarations/convertDiscriminatedUnionTypeDeclaration.ts b/packages/cli/generation/ir-generator/src/converters/type-declarations/convertDiscriminatedUnionTypeDeclaration.ts index 753c959aa9c..1c5278d33d3 100644 --- a/packages/cli/generation/ir-generator/src/converters/type-declarations/convertDiscriminatedUnionTypeDeclaration.ts +++ b/packages/cli/generation/ir-generator/src/converters/type-declarations/convertDiscriminatedUnionTypeDeclaration.ts @@ -8,7 +8,7 @@ import { getDisplayName } from "../../utils/getDisplayName"; import { getDocs } from "../../utils/getDocs"; import { parseTypeName } from "../../utils/parseTypeName"; import { convertDeclaration } from "../convertDeclaration"; -import { getExtensionsAsList, getPropertyName } from "./convertObjectTypeDeclaration"; +import { getExtensionsAsList, getPropertyAccess, getPropertyName } from "./convertObjectTypeDeclaration"; const DEFAULT_UNION_VALUE_PROPERTY_VALUE = "value"; @@ -37,9 +37,7 @@ export function convertDiscriminatedUnionTypeDeclaration({ name: getPropertyName({ propertyKey, property: propertyDefinition }).name }), valueType: file.parseTypeReference(propertyDefinition), - - // TODO(amckinney): Add support for property access. - propertyAccess: undefined + propertyAccess: getPropertyAccess({ property: propertyDefinition }) })) : [], types: Object.entries(union.union).map(([unionKey, rawSingleUnionType]): SingleUnionType => { diff --git a/packages/cli/generation/ir-generator/src/converters/type-declarations/convertObjectTypeDeclaration.ts b/packages/cli/generation/ir-generator/src/converters/type-declarations/convertObjectTypeDeclaration.ts index 92173e9766b..be5f9151ac7 100644 --- a/packages/cli/generation/ir-generator/src/converters/type-declarations/convertObjectTypeDeclaration.ts +++ b/packages/cli/generation/ir-generator/src/converters/type-declarations/convertObjectTypeDeclaration.ts @@ -1,5 +1,6 @@ +import { assertNever } from "@fern-api/core-utils"; import { RawSchemas } from "@fern-api/fern-definition-schema"; -import { ObjectProperty, Type } from "@fern-api/ir-sdk"; +import { ObjectProperty, ObjectPropertyAccess, Type } from "@fern-api/ir-sdk"; import { FernFileContext } from "../../FernFileContext"; import { parseTypeName } from "../../utils/parseTypeName"; @@ -34,9 +35,7 @@ export function getObjectPropertiesFromRawObjectSchema( name: getPropertyName({ propertyKey, property: propertyDefinition }).name }), valueType: file.parseTypeReference(propertyDefinition), - - // TODO(amckinney): Add support for property access. - propertyAccess: undefined + propertyAccess: getPropertyAccess({ property: propertyDefinition }) })); } @@ -69,3 +68,25 @@ export function getPropertyName({ wasExplicitlySet: false }; } + +export function getPropertyAccess({ + property +}: { + property: RawSchemas.ObjectPropertySchema; +}): ObjectPropertyAccess | undefined { + if (typeof property === "string") { + return undefined; + } + const propertyAccess = property["access"]; + if (propertyAccess != null) { + switch (propertyAccess) { + case "read-only": + return ObjectPropertyAccess.ReadOnly; + case "write-only": + return ObjectPropertyAccess.WriteOnly; + default: + assertNever(propertyAccess); + } + } + return undefined; +} diff --git a/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/csharp-property-access.json b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/csharp-property-access.json new file mode 100644 index 00000000000..d33234fb93f --- /dev/null +++ b/packages/cli/register/src/ir-to-fdr-converter/__test__/__snapshots__/csharp-property-access.json @@ -0,0 +1,147 @@ +{ + "types": { + "type_:User": { + "name": "User", + "shape": { + "type": "object", + "extends": [], + "properties": [ + { + "key": "id", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } + } + }, + { + "key": "name", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } + } + }, + { + "key": "email", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } + } + }, + { + "key": "password", + "valueType": { + "type": "primitive", + "value": { + "type": "string" + } + } + } + ] + } + } + }, + "subpackages": {}, + "rootPackage": { + "endpoints": [ + { + "auth": false, + "method": "POST", + "id": "createUser", + "originalEndpointId": "endpoint_.createUser", + "name": "Create User", + "path": { + "pathParameters": [], + "parts": [ + { + "type": "literal", + "value": "/users" + }, + { + "type": "literal", + "value": "" + } + ] + }, + "queryParameters": [], + "headers": [], + "request": { + "type": { + "type": "json", + "contentType": "application/json", + "shape": { + "type": "reference", + "value": { + "type": "id", + "value": "type_:User" + } + } + } + }, + "response": { + "type": { + "type": "reference", + "value": { + "type": "id", + "value": "type_:User" + } + } + }, + "errorsV2": [], + "examples": [ + { + "path": "/users", + "pathParameters": {}, + "queryParameters": {}, + "headers": {}, + "requestBody": { + "id": "id", + "name": "name", + "email": "email", + "password": "password" + }, + "requestBodyV3": { + "type": "json", + "value": { + "id": "id", + "name": "name", + "email": "email", + "password": "password" + } + }, + "responseStatusCode": 200, + "responseBody": { + "id": "id", + "name": "name", + "email": "email", + "password": "password" + }, + "responseBodyV3": { + "type": "json", + "value": { + "id": "id", + "name": "name", + "email": "email", + "password": "password" + } + }, + "codeSamples": [] + } + ] + } + ], + "webhooks": [], + "websockets": [], + "types": [ + "type_:User" + ], + "subpackages": [] + }, + "snippetsConfiguration": {}, + "globalHeaders": [] +} \ No newline at end of file diff --git a/packages/cli/workspace/lazy-fern-workspace/src/fern.schema.json b/packages/cli/workspace/lazy-fern-workspace/src/fern.schema.json index 58d12d76a1c..6278e6169cf 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/fern.schema.json +++ b/packages/cli/workspace/lazy-fern-workspace/src/fern.schema.json @@ -356,9 +356,19 @@ } ] }, - "types.TypeReferenceDeclarationWithName": { + "types.ObjectPropertyAccess": { + "type": "string", + "enum": [ + "read-only", + "write-only" + ] + }, + "types.ObjectPropertyWithAccessSchema": { "type": "object", "properties": { + "type": { + "type": "string" + }, "default": { "oneOf": [ { @@ -439,8 +449,15 @@ } ] }, - "type": { - "type": "string" + "access": { + "oneOf": [ + { + "$ref": "#/definitions/types.ObjectPropertyAccess" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -448,19 +465,16 @@ ], "additionalProperties": false }, - "types.TypeReferenceDeclarationWithNameSchema": { + "types.ObjectPropertySchema": { "anyOf": [ { "type": "string" }, { - "$ref": "#/definitions/types.TypeReferenceDeclarationWithName" + "$ref": "#/definitions/types.ObjectPropertyWithAccessSchema" } ] }, - "types.ObjectPropertySchema": { - "$ref": "#/definitions/types.TypeReferenceDeclarationWithNameSchema" - }, "types.ObjectSchema": { "type": "object", "properties": { diff --git a/packages/cli/workspace/lazy-fern-workspace/src/package-yml.schema.json b/packages/cli/workspace/lazy-fern-workspace/src/package-yml.schema.json index 5a99425c680..aecba620717 100644 --- a/packages/cli/workspace/lazy-fern-workspace/src/package-yml.schema.json +++ b/packages/cli/workspace/lazy-fern-workspace/src/package-yml.schema.json @@ -376,9 +376,19 @@ } ] }, - "types.TypeReferenceDeclarationWithName": { + "types.ObjectPropertyAccess": { + "type": "string", + "enum": [ + "read-only", + "write-only" + ] + }, + "types.ObjectPropertyWithAccessSchema": { "type": "object", "properties": { + "type": { + "type": "string" + }, "default": { "oneOf": [ { @@ -459,8 +469,15 @@ } ] }, - "type": { - "type": "string" + "access": { + "oneOf": [ + { + "$ref": "#/definitions/types.ObjectPropertyAccess" + }, + { + "type": "null" + } + ] } }, "required": [ @@ -468,19 +485,16 @@ ], "additionalProperties": false }, - "types.TypeReferenceDeclarationWithNameSchema": { + "types.ObjectPropertySchema": { "anyOf": [ { "type": "string" }, { - "$ref": "#/definitions/types.TypeReferenceDeclarationWithName" + "$ref": "#/definitions/types.ObjectPropertyWithAccessSchema" } ] }, - "types.ObjectPropertySchema": { - "$ref": "#/definitions/types.TypeReferenceDeclarationWithNameSchema" - }, "types.ObjectSchema": { "type": "object", "properties": { diff --git a/seed/csharp-model/csharp-property-access/.github/workflows/ci.yml b/seed/csharp-model/csharp-property-access/.github/workflows/ci.yml new file mode 100644 index 00000000000..d7df5b9fe4c --- /dev/null +++ b/seed/csharp-model/csharp-property-access/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: ci + +on: [push] + +jobs: + compile: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - uses: actions/checkout@master + + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 8.x + + - name: Install tools + run: | + dotnet tool restore + + - name: Build Release + run: dotnet build src -c Release /p:ContinuousIntegrationBuild=true + + unit-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - uses: actions/checkout@master + + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 8.x + + - name: Install tools + run: | + dotnet tool restore + + - name: Run Tests + run: | + dotnet test src + + + publish: + needs: [compile] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 8.x + + - name: Publish + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_TOKEN }} + run: | + dotnet pack src -c Release + dotnet nuget push src/SeedCsharpAccess/bin/Release/*.nupkg --api-key $NUGET_API_KEY --source "nuget.org" diff --git a/seed/csharp-model/csharp-property-access/.gitignore b/seed/csharp-model/csharp-property-access/.gitignore new file mode 100644 index 00000000000..11014f2b33d --- /dev/null +++ b/seed/csharp-model/csharp-property-access/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +## This is based on `dotnet new gitignore` and customized by Fern + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +# [Rr]elease/ (Ignored by Fern) +# [Rr]eleases/ (Ignored by Fern) +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +# [Ll]og/ (Ignored by Fern) +# [Ll]ogs/ (Ignored by Fern) + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/seed/csharp-model/csharp-property-access/.mock/definition/__package__.yml b/seed/csharp-model/csharp-property-access/.mock/definition/__package__.yml new file mode 100644 index 00000000000..163885d207e --- /dev/null +++ b/seed/csharp-model/csharp-property-access/.mock/definition/__package__.yml @@ -0,0 +1,21 @@ +types: + User: + properties: + id: + type: string + access: read-only + name: string + email: string + password: + type: string + access: write-only + +service: + auth: false + base-path: /users + endpoints: + createUser: + method: POST + path: "" + request: User + response: User \ No newline at end of file diff --git a/seed/csharp-model/csharp-property-access/.mock/definition/api.yml b/seed/csharp-model/csharp-property-access/.mock/definition/api.yml new file mode 100644 index 00000000000..773f9b4ea27 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/.mock/definition/api.yml @@ -0,0 +1 @@ +name: csharp-access diff --git a/seed/csharp-model/csharp-property-access/.mock/fern.config.json b/seed/csharp-model/csharp-property-access/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/csharp-model/csharp-property-access/.mock/generators.yml b/seed/csharp-model/csharp-property-access/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/csharp-model/csharp-property-access/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/csharp-model/csharp-property-access/snippet-templates.json b/seed/csharp-model/csharp-property-access/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/csharp-model/csharp-property-access/snippet.json b/seed/csharp-model/csharp-property-access/snippet.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateOnlyJsonTests.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateOnlyJsonTests.cs new file mode 100644 index 00000000000..0020a414f84 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateOnlyJsonTests.cs @@ -0,0 +1,76 @@ +using NUnit.Framework; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess.Test.Core.Json; + +[TestFixture] +public class DateOnlyJsonTests +{ + [Test] + public void SerializeDateOnly_ShouldMatchExpectedFormat() + { + (DateOnly dateOnly, string expected)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (new DateOnly(2023, 1, 1), "\"2023-01-01\""), + (new DateOnly(2023, 12, 31), "\"2023-12-31\""), + (new DateOnly(2023, 6, 15), "\"2023-06-15\""), + (new DateOnly(2023, 3, 10), "\"2023-03-10\""), + ]; + foreach (var (dateOnly, expected) in testCases) + { + var json = JsonUtils.Serialize(dateOnly); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeDateOnly_ShouldMatchExpectedDateOnly() + { + (DateOnly expected, string json)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (new DateOnly(2023, 1, 1), "\"2023-01-01\""), + (new DateOnly(2023, 12, 31), "\"2023-12-31\""), + (new DateOnly(2023, 6, 15), "\"2023-06-15\""), + (new DateOnly(2023, 3, 10), "\"2023-03-10\""), + ]; + + foreach (var (expected, json) in testCases) + { + var dateOnly = JsonUtils.Deserialize(json); + Assert.That(dateOnly, Is.EqualTo(expected)); + } + } + + [Test] + public void SerializeNullableDateOnly_ShouldMatchExpectedFormat() + { + (DateOnly? dateOnly, string expected)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (null, "null"), + ]; + foreach (var (dateOnly, expected) in testCases) + { + var json = JsonUtils.Serialize(dateOnly); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeNullableDateOnly_ShouldMatchExpectedDateOnly() + { + (DateOnly? expected, string json)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateOnly = JsonUtils.Deserialize(json); + Assert.That(dateOnly, Is.EqualTo(expected)); + } + } +} diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateTimeJsonTests.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateTimeJsonTests.cs new file mode 100644 index 00000000000..62965eb7eb0 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateTimeJsonTests.cs @@ -0,0 +1,110 @@ +using NUnit.Framework; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess.Test.Core.Json; + +[TestFixture] +public class DateTimeJsonTests +{ + [Test] + public void SerializeDateTime_ShouldMatchExpectedFormat() + { + (DateTime dateTime, string expected)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), "\"2023-01-01T00:00:00.000Z\""), + ( + new DateTime(2023, 12, 31, 23, 59, 59, DateTimeKind.Utc), + "\"2023-12-31T23:59:59.000Z\"" + ), + (new DateTime(2023, 6, 15, 12, 0, 0, DateTimeKind.Utc), "\"2023-06-15T12:00:00.000Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.000Z\"" + ), + ( + new DateTime(2023, 3, 10, 8, 45, 30, 123, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.123Z\"" + ), + ]; + foreach (var (dateTime, expected) in testCases) + { + var json = JsonUtils.Serialize(dateTime); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeDateTime_ShouldMatchExpectedDateTime() + { + (DateTime expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), "\"2023-01-01T00:00:00.000Z\""), + ( + new DateTime(2023, 12, 31, 23, 59, 59, DateTimeKind.Utc), + "\"2023-12-31T23:59:59.000Z\"" + ), + (new DateTime(2023, 6, 15, 12, 0, 0, DateTimeKind.Utc), "\"2023-06-15T12:00:00.000Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.000Z\"" + ), + (new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), "\"2023-03-10T08:45:30Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, 123, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.123Z\"" + ), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } + + [Test] + public void SerializeNullableDateTime_ShouldMatchExpectedFormat() + { + (DateTime? expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeNullableDateTime_ShouldMatchExpectedDateTime() + { + (DateTime? expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } +} diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/EnumSerializerTests.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/EnumSerializerTests.cs new file mode 100644 index 00000000000..089b912a07e --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/EnumSerializerTests.cs @@ -0,0 +1,60 @@ +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; +using NUnit.Framework; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess.Test.Core.Json; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class StringEnumSerializerTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private const DummyEnum KnownEnumValue2 = DummyEnum.KnownValue2; + private const string KnownEnumValue2String = "known_value2"; + + private const string JsonWithKnownEnum2 = $$""" + { + "enum_property": "{{KnownEnumValue2String}}" + } + """; + + [Test] + public void ShouldParseKnownEnumValue2() + { + var obj = JsonSerializer.Deserialize(JsonWithKnownEnum2, JsonOptions); + Assert.That(obj, Is.Not.Null); + Assert.That(obj.EnumProperty, Is.EqualTo(KnownEnumValue2)); + } + + [Test] + public void ShouldSerializeKnownEnumValue2() + { + var json = JsonSerializer.SerializeToElement( + new DummyObject { EnumProperty = KnownEnumValue2 }, + JsonOptions + ); + TestContext.Out.WriteLine("Serialized JSON: \n" + json); + var enumString = json.GetProperty("enum_property").GetString(); + Assert.That(enumString, Is.Not.Null); + Assert.That(enumString, Is.EqualTo(KnownEnumValue2String)); + } +} + +public class DummyObject +{ + [JsonPropertyName("enum_property")] + public DummyEnum EnumProperty { get; set; } +} + +[JsonConverter(typeof(EnumSerializer))] +public enum DummyEnum +{ + [EnumMember(Value = "known_value1")] + KnownValue1, + + [EnumMember(Value = "known_value2")] + KnownValue2, +} diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/OneOfSerializerTests.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/OneOfSerializerTests.cs new file mode 100644 index 00000000000..27ed85cb52a --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/OneOfSerializerTests.cs @@ -0,0 +1,311 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NUnit.Framework; +using OneOf; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess.Test.Core; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class OneOfSerializerTests +{ + private class Foo + { + [JsonPropertyName("string_prop")] + public required string StringProp { get; set; } + } + + private class Bar + { + [JsonPropertyName("int_prop")] + public required int IntProp { get; set; } + } + + private static readonly OneOf OneOf1 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT2(new { }); + private const string OneOf1String = "{}"; + + private static readonly OneOf OneOf2 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT0("test"); + private const string OneOf2String = "\"test\""; + + private static readonly OneOf OneOf3 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT1(123); + private const string OneOf3String = "123"; + + private static readonly OneOf OneOf4 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT3(new Foo { StringProp = "test" }); + private const string OneOf4String = "{\n \"string_prop\": \"test\"\n}"; + + private static readonly OneOf OneOf5 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT4(new Bar { IntProp = 5 }); + private const string OneOf5String = "{\n \"int_prop\": 5\n}"; + + [Test] + public void Serialize_OneOfs_Should_Return_Expected_String() + { + (OneOf, string)[] testData = + [ + (OneOf1, OneOf1String), + (OneOf2, OneOf2String), + (OneOf3, OneOf3String), + (OneOf4, OneOf4String), + (OneOf5, OneOf5String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected)); + } + }); + } + + [Test] + public void OneOfs_Should_Deserialize_From_String() + { + (OneOf, string)[] testData = + [ + (OneOf1, OneOf1String), + (OneOf2, OneOf2String), + (OneOf3, OneOf3String), + (OneOf4, OneOf4String), + (OneOf5, OneOf5String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize>(json); + Assert.That(result.Index, Is.EqualTo(oneOf.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result.Value))); + } + }); + } + + private static readonly OneOf? NullableOneOf1 = null; + private const string NullableOneOf1String = "null"; + + private static readonly OneOf? NullableOneOf2 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT4(new Bar { IntProp = 5 }); + private const string NullableOneOf2String = "{\n \"int_prop\": 5\n}"; + + [Test] + public void Serialize_NullableOneOfs_Should_Return_Expected_String() + { + (OneOf?, string)[] testData = + [ + (NullableOneOf1, NullableOneOf1String), + (NullableOneOf2, NullableOneOf2String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected)); + } + }); + } + + [Test] + public void NullableOneOfs_Should_Deserialize_From_String() + { + (OneOf?, string)[] testData = + [ + (NullableOneOf1, NullableOneOf1String), + (NullableOneOf2, NullableOneOf2String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize?>(json); + Assert.That(result?.Index, Is.EqualTo(oneOf?.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result?.Value))); + } + }); + } + + private static readonly OneOf OneOfWithNullable1 = OneOf< + string, + int, + Foo? + >.FromT2(null); + private const string OneOfWithNullable1String = "null"; + + private static readonly OneOf OneOfWithNullable2 = OneOf< + string, + int, + Foo? + >.FromT2(new Foo { StringProp = "test" }); + private const string OneOfWithNullable2String = "{\n \"string_prop\": \"test\"\n}"; + + private static readonly OneOf OneOfWithNullable3 = OneOf< + string, + int, + Foo? + >.FromT0("test"); + private const string OneOfWithNullable3String = "\"test\""; + + [Test] + public void Serialize_OneOfWithNullables_Should_Return_Expected_String() + { + (OneOf, string)[] testData = + [ + (OneOfWithNullable1, OneOfWithNullable1String), + (OneOfWithNullable2, OneOfWithNullable2String), + (OneOfWithNullable3, OneOfWithNullable3String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected)); + } + }); + } + + [Test] + public void OneOfWithNullables_Should_Deserialize_From_String() + { + (OneOf, string)[] testData = + [ + // (OneOfWithNullable1, OneOfWithNullable1String), // not possible with .NET's JSON serializer + (OneOfWithNullable2, OneOfWithNullable2String), + (OneOfWithNullable3, OneOfWithNullable3String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize>(json); + Assert.That(result.Index, Is.EqualTo(oneOf.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result.Value))); + } + }); + } + + [Test] + public void Serialize_OneOfWithObjectLast_Should_Return_Expected_String() + { + var oneOfWithObjectLast = OneOf.FromT4( + new { random = "data" } + ); + const string oneOfWithObjectLastString = "{\n \"random\": \"data\"\n}"; + + var result = JsonUtils.Serialize(oneOfWithObjectLast); + Assert.That(result, Is.EqualTo(oneOfWithObjectLastString)); + } + + [Test] + public void OneOfWithObjectLast_Should_Deserialize_From_String() + { + const string oneOfWithObjectLastString = "{\n \"random\": \"data\"\n}"; + var result = JsonUtils.Deserialize>( + oneOfWithObjectLastString + ); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(4)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That(JsonUtils.Serialize(result.Value), Is.EqualTo(oneOfWithObjectLastString)); + }); + } + + [Test] + public void Serialize_OneOfWithObjectNotLast_Should_Return_Expected_String() + { + var oneOfWithObjectNotLast = OneOf.FromT1( + new { random = "data" } + ); + const string oneOfWithObjectNotLastString = "{\n \"random\": \"data\"\n}"; + + var result = JsonUtils.Serialize(oneOfWithObjectNotLast); + Assert.That(result, Is.EqualTo(oneOfWithObjectNotLastString)); + } + + [Test] + public void OneOfWithObjectNotLast_Should_Deserialize_From_String() + { + const string oneOfWithObjectNotLastString = "{\n \"random\": \"data\"\n}"; + var result = JsonUtils.Deserialize>( + oneOfWithObjectNotLastString + ); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(1)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That( + JsonUtils.Serialize(result.Value), + Is.EqualTo(oneOfWithObjectNotLastString) + ); + }); + } + + [Test] + public void Serialize_OneOfSingleType_Should_Return_Expected_String() + { + var oneOfSingle = OneOf.FromT0("single"); + const string oneOfSingleString = "\"single\""; + + var result = JsonUtils.Serialize(oneOfSingle); + Assert.That(result, Is.EqualTo(oneOfSingleString)); + } + + [Test] + public void OneOfSingleType_Should_Deserialize_From_String() + { + const string oneOfSingleString = "\"single\""; + var result = JsonUtils.Deserialize>(oneOfSingleString); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(0)); + Assert.That(result.Value, Is.EqualTo("single")); + }); + } + + [Test] + public void Deserialize_InvalidData_Should_Throw_Exception() + { + const string invalidJson = "{\"invalid\": \"data\"}"; + + Assert.Throws(() => + { + JsonUtils.Deserialize>(invalidJson); + }); + } +} diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.Custom.props b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.Custom.props new file mode 100644 index 00000000000..55e683b0772 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.Custom.props @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.csproj b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.csproj new file mode 100644 index 00000000000..3b75989d4c4 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.csproj @@ -0,0 +1,38 @@ + + + + net8.0 + 12 + enable + enable + false + true + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + \ No newline at end of file diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/CollectionItemSerializer.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/CollectionItemSerializer.cs new file mode 100644 index 00000000000..a4ca9c17ec7 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/CollectionItemSerializer.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCsharpAccess.Core; + +/// +/// Json collection converter. +/// +/// Type of item to convert. +/// Converter to use for individual items. +internal class CollectionItemSerializer + : JsonConverter> + where TConverterType : JsonConverter +{ + /// + /// Reads a json string and deserializes it into an object. + /// + /// Json reader. + /// Type to convert. + /// Serializer options. + /// Created object. + public override IEnumerable? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return default; + } + + var jsonSerializerOptions = new JsonSerializerOptions(options); + jsonSerializerOptions.Converters.Clear(); + jsonSerializerOptions.Converters.Add(Activator.CreateInstance()); + + var returnValue = new List(); + + while (reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType != JsonTokenType.StartArray) + { + var item = (TDatatype)( + JsonSerializer.Deserialize(ref reader, typeof(TDatatype), jsonSerializerOptions) + ?? throw new Exception( + $"Failed to deserialize collection item of type {typeof(TDatatype)}" + ) + ); + returnValue.Add(item); + } + + reader.Read(); + } + + return returnValue; + } + + /// + /// Writes a json string. + /// + /// Json writer. + /// Value to write. + /// Serializer options. + public override void Write( + Utf8JsonWriter writer, + IEnumerable? value, + JsonSerializerOptions options + ) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + var jsonSerializerOptions = new JsonSerializerOptions(options); + jsonSerializerOptions.Converters.Clear(); + jsonSerializerOptions.Converters.Add(Activator.CreateInstance()); + + writer.WriteStartArray(); + + foreach (var data in value) + { + JsonSerializer.Serialize(writer, data, jsonSerializerOptions); + } + + writer.WriteEndArray(); + } +} diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/Constants.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/Constants.cs new file mode 100644 index 00000000000..410015c69b3 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/Constants.cs @@ -0,0 +1,7 @@ +namespace SeedCsharpAccess.Core; + +internal static class Constants +{ + public const string DateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.fffK"; + public const string DateFormat = "yyyy-MM-dd"; +} diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/DateOnlyConverter.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/DateOnlyConverter.cs new file mode 100644 index 00000000000..ea6af4b07f7 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/DateOnlyConverter.cs @@ -0,0 +1,747 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// ReSharper disable All + +using global::System.Diagnostics; +using global::System.Diagnostics.CodeAnalysis; +using global::System.Globalization; +using global::System.Runtime.CompilerServices; +using global::System.Runtime.InteropServices; +using global::System.Text.Json; +using global::System.Text.Json.Serialization; + +// ReSharper disable SuggestVarOrType_SimpleTypes +// ReSharper disable SuggestVarOrType_BuiltInTypes + +namespace SeedCsharpAccess.Core +{ + /// + /// Custom converter for handling the data type with the System.Text.Json library. + /// + /// + /// This class backported from: + /// + /// System.Text.Json.Serialization.Converters.DateOnlyConverter + /// + public sealed class DateOnlyConverter : JsonConverter + { + private const int FormatLength = 10; // YYYY-MM-DD + + private const int MaxEscapedFormatLength = + FormatLength * JsonConstants.MaxExpansionFactorWhileEscaping; + + /// + public override DateOnly Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType != JsonTokenType.String) + { + ThrowHelper.ThrowInvalidOperationException_ExpectedString(reader.TokenType); + } + + return ReadCore(ref reader); + } + + /// + public override DateOnly ReadAsPropertyName( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); + return ReadCore(ref reader); + } + + private static DateOnly ReadCore(ref Utf8JsonReader reader) + { + if ( + !JsonHelpers.IsInRangeInclusive( + reader.ValueLength(), + FormatLength, + MaxEscapedFormatLength + ) + ) + { + ThrowHelper.ThrowFormatException(DataType.DateOnly); + } + + scoped ReadOnlySpan source; + if (!reader.HasValueSequence && !reader.ValueIsEscaped) + { + source = reader.ValueSpan; + } + else + { + Span stackSpan = stackalloc byte[MaxEscapedFormatLength]; + int bytesWritten = reader.CopyString(stackSpan); + source = stackSpan.Slice(0, bytesWritten); + } + + if (!JsonHelpers.TryParseAsIso(source, out DateOnly value)) + { + ThrowHelper.ThrowFormatException(DataType.DateOnly); + } + + return value; + } + + /// + public override void Write( + Utf8JsonWriter writer, + DateOnly value, + JsonSerializerOptions options + ) + { +#if NET8_0_OR_GREATER + Span buffer = stackalloc byte[FormatLength]; +#else + Span buffer = stackalloc char[FormatLength]; +#endif + // ReSharper disable once RedundantAssignment + bool formattedSuccessfully = value.TryFormat( + buffer, + out int charsWritten, + "O".AsSpan(), + CultureInfo.InvariantCulture + ); + Debug.Assert(formattedSuccessfully && charsWritten == FormatLength); + writer.WriteStringValue(buffer); + } + + /// + public override void WriteAsPropertyName( + Utf8JsonWriter writer, + DateOnly value, + JsonSerializerOptions options + ) + { +#if NET8_0_OR_GREATER + Span buffer = stackalloc byte[FormatLength]; +#else + Span buffer = stackalloc char[FormatLength]; +#endif + // ReSharper disable once RedundantAssignment + bool formattedSuccessfully = value.TryFormat( + buffer, + out int charsWritten, + "O".AsSpan(), + CultureInfo.InvariantCulture + ); + Debug.Assert(formattedSuccessfully && charsWritten == FormatLength); + writer.WritePropertyName(buffer); + } + } + + internal static class JsonConstants + { + // The maximum number of fraction digits the Json DateTime parser allows + public const int DateTimeParseNumFractionDigits = 16; + + // In the worst case, an ASCII character represented as a single utf-8 byte could expand 6x when escaped. + public const int MaxExpansionFactorWhileEscaping = 6; + + // The largest fraction expressible by TimeSpan and DateTime formats + public const int MaxDateTimeFraction = 9_999_999; + + // TimeSpan and DateTime formats allow exactly up to many digits for specifying the fraction after the seconds. + public const int DateTimeNumFractionDigits = 7; + + public const byte UtcOffsetToken = (byte)'Z'; + + public const byte TimePrefix = (byte)'T'; + + public const byte Period = (byte)'.'; + + public const byte Hyphen = (byte)'-'; + + public const byte Colon = (byte)':'; + + public const byte Plus = (byte)'+'; + } + + // ReSharper disable SuggestVarOrType_Elsewhere + // ReSharper disable SuggestVarOrType_SimpleTypes + // ReSharper disable SuggestVarOrType_BuiltInTypes + + + internal static class JsonHelpers + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsInRangeInclusive(int value, int lowerBound, int upperBound) => + (uint)(value - lowerBound) <= (uint)(upperBound - lowerBound); + + public static bool IsDigit(byte value) => (uint)(value - '0') <= '9' - '0'; + + [StructLayout(LayoutKind.Auto)] + private struct DateTimeParseData + { + public int Year; + public int Month; + public int Day; + public bool IsCalendarDateOnly; + public int Hour; + public int Minute; + public int Second; + public int Fraction; // This value should never be greater than 9_999_999. + public int OffsetHours; + public int OffsetMinutes; + + // ReSharper disable once NotAccessedField.Local + public byte OffsetToken; + } + + public static bool TryParseAsIso(ReadOnlySpan source, out DateOnly value) + { + if ( + TryParseDateTimeOffset(source, out DateTimeParseData parseData) + && parseData.IsCalendarDateOnly + && TryCreateDateTime(parseData, DateTimeKind.Unspecified, out DateTime dateTime) + ) + { + value = DateOnly.FromDateTime(dateTime); + return true; + } + + value = default; + return false; + } + + /// + /// ISO 8601 date time parser (ISO 8601-1:2019). + /// + /// The date/time to parse in UTF-8 format. + /// The parsed for the given . + /// + /// Supports extended calendar date (5.2.2.1) and complete (5.4.2.1) calendar date/time of day + /// representations with optional specification of seconds and fractional seconds. + /// + /// Times can be explicitly specified as UTC ("Z" - 5.3.3) or offsets from UTC ("+/-hh:mm" 5.3.4.2). + /// If unspecified they are considered to be local per spec. + /// + /// Examples: (TZD is either "Z" or hh:mm offset from UTC) + /// + /// YYYY-MM-DD (e.g. 1997-07-16) + /// YYYY-MM-DDThh:mm (e.g. 1997-07-16T19:20) + /// YYYY-MM-DDThh:mm:ss (e.g. 1997-07-16T19:20:30) + /// YYYY-MM-DDThh:mm:ss.s (e.g. 1997-07-16T19:20:30.45) + /// YYYY-MM-DDThh:mmTZD (e.g. 1997-07-16T19:20+01:00) + /// YYYY-MM-DDThh:mm:ssTZD (e.g. 1997-07-16T19:20:3001:00) + /// YYYY-MM-DDThh:mm:ss.sTZD (e.g. 1997-07-16T19:20:30.45Z) + /// + /// Generally speaking we always require the "extended" option when one exists (3.1.3.5). + /// The extended variants have separator characters between components ('-', ':', '.', etc.). + /// Spaces are not permitted. + /// + /// "true" if successfully parsed. + private static bool TryParseDateTimeOffset( + ReadOnlySpan source, + out DateTimeParseData parseData + ) + { + parseData = default; + + // too short datetime + Debug.Assert(source.Length >= 10); + + // Parse the calendar date + // ----------------------- + // ISO 8601-1:2019 5.2.2.1b "Calendar date complete extended format" + // [dateX] = [year]["-"][month]["-"][day] + // [year] = [YYYY] [0000 - 9999] (4.3.2) + // [month] = [MM] [01 - 12] (4.3.3) + // [day] = [DD] [01 - 28, 29, 30, 31] (4.3.4) + // + // Note: 5.2.2.2 "Representations with reduced precision" allows for + // just [year]["-"][month] (a) and just [year] (b), but we currently + // don't permit it. + + { + uint digit1 = source[0] - (uint)'0'; + uint digit2 = source[1] - (uint)'0'; + uint digit3 = source[2] - (uint)'0'; + uint digit4 = source[3] - (uint)'0'; + + if (digit1 > 9 || digit2 > 9 || digit3 > 9 || digit4 > 9) + { + return false; + } + + parseData.Year = (int)(digit1 * 1000 + digit2 * 100 + digit3 * 10 + digit4); + } + + if ( + source[4] != JsonConstants.Hyphen + || !TryGetNextTwoDigits(source.Slice(start: 5, length: 2), ref parseData.Month) + || source[7] != JsonConstants.Hyphen + || !TryGetNextTwoDigits(source.Slice(start: 8, length: 2), ref parseData.Day) + ) + { + return false; + } + + // We now have YYYY-MM-DD [dateX] + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (source.Length == 10) + { + parseData.IsCalendarDateOnly = true; + return true; + } + + // Parse the time of day + // --------------------- + // + // ISO 8601-1:2019 5.3.1.2b "Local time of day complete extended format" + // [timeX] = ["T"][hour][":"][min][":"][sec] + // [hour] = [hh] [00 - 23] (4.3.8a) + // [minute] = [mm] [00 - 59] (4.3.9a) + // [sec] = [ss] [00 - 59, 60 with a leap second] (4.3.10a) + // + // ISO 8601-1:2019 5.3.3 "UTC of day" + // [timeX]["Z"] + // + // ISO 8601-1:2019 5.3.4.2 "Local time of day with the time shift between + // local timescale and UTC" (Extended format) + // + // [shiftX] = ["+"|"-"][hour][":"][min] + // + // Notes: + // + // "T" is optional per spec, but _only_ when times are used alone. In our + // case, we're reading out a complete date & time and as such require "T". + // (5.4.2.1b). + // + // For [timeX] We allow seconds to be omitted per 5.3.1.3a "Representations + // with reduced precision". 5.3.1.3b allows just specifying the hour, but + // we currently don't permit this. + // + // Decimal fractions are allowed for hours, minutes and seconds (5.3.14). + // We only allow fractions for seconds currently. Lower order components + // can't follow, i.e. you can have T23.3, but not T23.3:04. There must be + // one digit, but the max number of digits is implementation defined. We + // currently allow up to 16 digits of fractional seconds only. While we + // support 16 fractional digits we only parse the first seven, anything + // past that is considered a zero. This is to stay compatible with the + // DateTime implementation which is limited to this resolution. + + if (source.Length < 16) + { + // Source does not have enough characters for YYYY-MM-DDThh:mm + return false; + } + + // Parse THH:MM (e.g. "T10:32") + if ( + source[10] != JsonConstants.TimePrefix + || source[13] != JsonConstants.Colon + || !TryGetNextTwoDigits(source.Slice(start: 11, length: 2), ref parseData.Hour) + || !TryGetNextTwoDigits(source.Slice(start: 14, length: 2), ref parseData.Minute) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm + Debug.Assert(source.Length >= 16); + if (source.Length == 16) + { + return true; + } + + byte curByte = source[16]; + int sourceIndex = 17; + + // Either a TZD ['Z'|'+'|'-'] or a seconds separator [':'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + case JsonConstants.Colon: + break; + default: + return false; + } + + // Try reading the seconds + if ( + source.Length < 19 + || !TryGetNextTwoDigits(source.Slice(start: 17, length: 2), ref parseData.Second) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm:ss + Debug.Assert(source.Length >= 19); + if (source.Length == 19) + { + return true; + } + + curByte = source[19]; + sourceIndex = 20; + + // Either a TZD ['Z'|'+'|'-'] or a seconds decimal fraction separator ['.'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + case JsonConstants.Period: + break; + default: + return false; + } + + // Source does not have enough characters for second fractions (i.e. ".s") + // YYYY-MM-DDThh:mm:ss.s + if (source.Length < 21) + { + return false; + } + + // Parse fraction. This value should never be greater than 9_999_999 + int numDigitsRead = 0; + int fractionEnd = Math.Min( + sourceIndex + JsonConstants.DateTimeParseNumFractionDigits, + source.Length + ); + + while (sourceIndex < fractionEnd && IsDigit(curByte = source[sourceIndex])) + { + if (numDigitsRead < JsonConstants.DateTimeNumFractionDigits) + { + parseData.Fraction = parseData.Fraction * 10 + (int)(curByte - (uint)'0'); + numDigitsRead++; + } + + sourceIndex++; + } + + if (parseData.Fraction != 0) + { + while (numDigitsRead < JsonConstants.DateTimeNumFractionDigits) + { + parseData.Fraction *= 10; + numDigitsRead++; + } + } + + // We now have YYYY-MM-DDThh:mm:ss.s + Debug.Assert(sourceIndex <= source.Length); + if (sourceIndex == source.Length) + { + return true; + } + + curByte = source[sourceIndex++]; + + // TZD ['Z'|'+'|'-'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + default: + return false; + } + + static bool ParseOffset(ref DateTimeParseData parseData, ReadOnlySpan offsetData) + { + // Parse the hours for the offset + if ( + offsetData.Length < 2 + || !TryGetNextTwoDigits(offsetData.Slice(0, 2), ref parseData.OffsetHours) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm:ss.s+|-hh + + if (offsetData.Length == 2) + { + // Just hours offset specified + return true; + } + + // Ensure we have enough for ":mm" + return offsetData.Length == 5 + && offsetData[2] == JsonConstants.Colon + && TryGetNextTwoDigits(offsetData.Slice(3), ref parseData.OffsetMinutes); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + // ReSharper disable once RedundantAssignment + private static bool TryGetNextTwoDigits(ReadOnlySpan source, ref int value) + { + Debug.Assert(source.Length == 2); + + uint digit1 = source[0] - (uint)'0'; + uint digit2 = source[1] - (uint)'0'; + + if (digit1 > 9 || digit2 > 9) + { + value = 0; + return false; + } + + value = (int)(digit1 * 10 + digit2); + return true; + } + + // The following methods are borrowed verbatim from src/Common/src/CoreLib/System/Buffers/Text/Utf8Parser/Utf8Parser.Date.Helpers.cs + + /// + /// Overflow-safe DateTime factory. + /// + private static bool TryCreateDateTime( + DateTimeParseData parseData, + DateTimeKind kind, + out DateTime value + ) + { + if (parseData.Year == 0) + { + value = default; + return false; + } + + Debug.Assert(parseData.Year <= 9999); // All of our callers to date parse the year from fixed 4-digit fields so this value is trusted. + + if ((uint)parseData.Month - 1 >= 12) + { + value = default; + return false; + } + + uint dayMinusOne = (uint)parseData.Day - 1; + if ( + dayMinusOne >= 28 + && dayMinusOne >= DateTime.DaysInMonth(parseData.Year, parseData.Month) + ) + { + value = default; + return false; + } + + if ((uint)parseData.Hour > 23) + { + value = default; + return false; + } + + if ((uint)parseData.Minute > 59) + { + value = default; + return false; + } + + // This needs to allow leap seconds when appropriate. + // See https://github.com/dotnet/runtime/issues/30135. + if ((uint)parseData.Second > 59) + { + value = default; + return false; + } + + Debug.Assert(parseData.Fraction is >= 0 and <= JsonConstants.MaxDateTimeFraction); // All of our callers to date parse the fraction from fixed 7-digit fields so this value is trusted. + + ReadOnlySpan days = DateTime.IsLeapYear(parseData.Year) + ? DaysToMonth366 + : DaysToMonth365; + int yearMinusOne = parseData.Year - 1; + int totalDays = + yearMinusOne * 365 + + yearMinusOne / 4 + - yearMinusOne / 100 + + yearMinusOne / 400 + + days[parseData.Month - 1] + + parseData.Day + - 1; + long ticks = totalDays * TimeSpan.TicksPerDay; + int totalSeconds = parseData.Hour * 3600 + parseData.Minute * 60 + parseData.Second; + ticks += totalSeconds * TimeSpan.TicksPerSecond; + ticks += parseData.Fraction; + value = new DateTime(ticks: ticks, kind: kind); + return true; + } + + private static ReadOnlySpan DaysToMonth365 => + [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365]; + private static ReadOnlySpan DaysToMonth366 => + [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366]; + } + + internal static class ThrowHelper + { + private const string ExceptionSourceValueToRethrowAsJsonException = + "System.Text.Json.Rethrowable"; + + [DoesNotReturn] + public static void ThrowInvalidOperationException_ExpectedString(JsonTokenType tokenType) + { + throw GetInvalidOperationException("string", tokenType); + } + + public static void ThrowFormatException(DataType dataType) + { + throw new FormatException(SR.Format(SR.UnsupportedFormat, dataType)) + { + Source = ExceptionSourceValueToRethrowAsJsonException, + }; + } + + private static Exception GetInvalidOperationException( + string message, + JsonTokenType tokenType + ) + { + return GetInvalidOperationException(SR.Format(SR.InvalidCast, tokenType, message)); + } + + private static InvalidOperationException GetInvalidOperationException(string message) + { + return new InvalidOperationException(message) + { + Source = ExceptionSourceValueToRethrowAsJsonException, + }; + } + } + + internal static class Utf8JsonReaderExtensions + { + internal static int ValueLength(this Utf8JsonReader reader) => + reader.HasValueSequence + ? checked((int)reader.ValueSequence.Length) + : reader.ValueSpan.Length; + } + + internal enum DataType + { + TimeOnly, + DateOnly, + } + + [SuppressMessage("ReSharper", "InconsistentNaming")] + internal static class SR + { + private static readonly bool s_usingResourceKeys = + AppContext.TryGetSwitch( + "System.Resources.UseSystemResourceKeys", + out bool usingResourceKeys + ) && usingResourceKeys; + + public static string UnsupportedFormat => Strings.UnsupportedFormat; + + public static string InvalidCast => Strings.InvalidCast; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string Format(string resourceFormat, object? p1) => + s_usingResourceKeys + ? string.Join(", ", resourceFormat, p1) + : string.Format(resourceFormat, p1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string Format(string resourceFormat, object? p1, object? p2) => + s_usingResourceKeys + ? string.Join(", ", resourceFormat, p1, p2) + : string.Format(resourceFormat, p1, p2); + } + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute( + "System.Resources.Tools.StronglyTypedResourceBuilder", + "17.0.0.0" + )] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Strings + { + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( + "Microsoft.Performance", + "CA1811:AvoidUncalledPrivateCode" + )] + internal Strings() { } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute( + global::System.ComponentModel.EditorBrowsableState.Advanced + )] + internal static global::System.Resources.ResourceManager ResourceManager + { + get + { + if (object.ReferenceEquals(resourceMan, null)) + { + global::System.Resources.ResourceManager temp = + new global::System.Resources.ResourceManager( + "System.Text.Json.Resources.Strings", + typeof(Strings).Assembly + ); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute( + global::System.ComponentModel.EditorBrowsableState.Advanced + )] + internal static global::System.Globalization.CultureInfo Culture + { + get { return resourceCulture; } + set { resourceCulture = value; } + } + + /// + /// Looks up a localized string similar to Cannot get the value of a token type '{0}' as a {1}.. + /// + internal static string InvalidCast + { + get { return ResourceManager.GetString("InvalidCast", resourceCulture); } + } + + /// + /// Looks up a localized string similar to The JSON value is not in a supported {0} format.. + /// + internal static string UnsupportedFormat + { + get { return ResourceManager.GetString("UnsupportedFormat", resourceCulture); } + } + } +} diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/DateTimeSerializer.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/DateTimeSerializer.cs new file mode 100644 index 00000000000..febf9435e0a --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/DateTimeSerializer.cs @@ -0,0 +1,22 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCsharpAccess.Core; + +internal class DateTimeSerializer : JsonConverter +{ + public override DateTime Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + return DateTime.Parse(reader.GetString()!, null, DateTimeStyles.RoundtripKind); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(Constants.DateTimeFormat)); + } +} diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/EnumSerializer.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/EnumSerializer.cs new file mode 100644 index 00000000000..da4570d74f4 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/EnumSerializer.cs @@ -0,0 +1,53 @@ +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCsharpAccess.Core; + +internal class EnumSerializer : JsonConverter + where TEnum : struct, Enum +{ + private readonly Dictionary _enumToString = new(); + private readonly Dictionary _stringToEnum = new(); + + public EnumSerializer() + { + var type = typeof(TEnum); + var values = Enum.GetValues(type); + + foreach (var value in values) + { + var enumValue = (TEnum)value; + var enumMember = type.GetField(enumValue.ToString())!; + var attr = enumMember + .GetCustomAttributes(typeof(EnumMemberAttribute), false) + .Cast() + .FirstOrDefault(); + + var stringValue = + attr?.Value + ?? value.ToString() + ?? throw new Exception("Unexpected null enum toString value"); + + _enumToString.Add(enumValue, stringValue); + _stringToEnum.Add(stringValue, enumValue); + } + } + + public override TEnum Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new Exception("The JSON value could not be read as a string."); + return _stringToEnum.TryGetValue(stringValue, out var enumValue) ? enumValue : default; + } + + public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) + { + writer.WriteStringValue(_enumToString[value]); + } +} diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/JsonConfiguration.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/JsonConfiguration.cs new file mode 100644 index 00000000000..be18948464e --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/JsonConfiguration.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCsharpAccess.Core; + +internal static partial class JsonOptions +{ + public static readonly JsonSerializerOptions JsonSerializerOptions; + + static JsonOptions() + { + var options = new JsonSerializerOptions + { + Converters = + { + new DateTimeSerializer(), +#if USE_PORTABLE_DATE_ONLY + new DateOnlyConverter(), +#endif + new OneOfSerializer(), + }, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + ConfigureJsonSerializerOptions(options); + JsonSerializerOptions = options; + } + + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); +} + +internal static class JsonUtils +{ + public static string Serialize(T obj) + { + return JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); + } + + public static string SerializeAsString(T obj) + { + var json = JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); + return json.Trim('"'); + } + + public static T Deserialize(string json) + { + return JsonSerializer.Deserialize(json, JsonOptions.JsonSerializerOptions)!; + } +} diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/OneOfSerializer.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/OneOfSerializer.cs new file mode 100644 index 00000000000..1a49b1efd55 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/OneOfSerializer.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using OneOf; + +namespace SeedCsharpAccess.Core; + +internal class OneOfSerializer : JsonConverter +{ + public override IOneOf? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType is JsonTokenType.Null) + return default; + + foreach (var (type, cast) in GetOneOfTypes(typeToConvert)) + { + try + { + var readerCopy = reader; + var result = JsonSerializer.Deserialize(ref readerCopy, type, options); + reader.Skip(); + return (IOneOf)cast.Invoke(null, [result])!; + } + catch (JsonException) { } + } + + throw new JsonException( + $"Cannot deserialize into one of the supported types for {typeToConvert}" + ); + } + + public override void Write(Utf8JsonWriter writer, IOneOf value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.Value, options); + } + + private static (global::System.Type type, MethodInfo cast)[] GetOneOfTypes( + global::System.Type typeToConvert + ) + { + var type = typeToConvert; + if (Nullable.GetUnderlyingType(type) is { } underlyingType) + { + type = underlyingType; + } + + var casts = type.GetRuntimeMethods() + .Where(m => m.IsSpecialName && m.Name == "op_Implicit") + .ToArray(); + while (type != null) + { + if ( + type.IsGenericType + && (type.Name.StartsWith("OneOf`") || type.Name.StartsWith("OneOfBase`")) + ) + { + var genericArguments = type.GetGenericArguments(); + if (genericArguments.Length == 1) + { + return [(genericArguments[0], casts[0])]; + } + + // if object type is present, make sure it is last + var indexOfObjectType = Array.IndexOf(genericArguments, typeof(object)); + if (indexOfObjectType != -1 && genericArguments.Length - 1 != indexOfObjectType) + { + genericArguments = genericArguments + .OrderBy(t => t == typeof(object) ? 1 : 0) + .ToArray(); + } + + return genericArguments + .Select(t => (t, casts.First(c => c.GetParameters()[0].ParameterType == t))) + .ToArray(); + } + + type = type.BaseType; + } + + throw new InvalidOperationException($"{type} isn't OneOf or OneOfBase"); + } + + public override bool CanConvert(global::System.Type typeToConvert) + { + return typeof(IOneOf).IsAssignableFrom(typeToConvert); + } +} diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/Public/Version.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/Public/Version.cs new file mode 100644 index 00000000000..1fe989f5c6e --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/Core/Public/Version.cs @@ -0,0 +1,6 @@ +namespace SeedCsharpAccess; + +internal class Version +{ + public const string Current = "0.0.1"; +} diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.Custom.props b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.Custom.props new file mode 100644 index 00000000000..70df2849401 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.Custom.props @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.csproj b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.csproj new file mode 100644 index 00000000000..a6fb26489a0 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.csproj @@ -0,0 +1,53 @@ + + + + net462;net8.0;net7.0;net6.0;netstandard2.0 + enable + 12 + enable + 0.0.1 + $(Version) + $(Version) + README.md + https://github.com/csharp-property-access/fern + true + + + + false + + + $(DefineConstants);USE_PORTABLE_DATE_ONLY + true + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + <_Parameter1>SeedCsharpAccess.Test + + + + + diff --git a/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/User.cs b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/User.cs new file mode 100644 index 00000000000..ee4caffccf8 --- /dev/null +++ b/seed/csharp-model/csharp-property-access/src/SeedCsharpAccess/User.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess; + +public record User +{ + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("email")] + public required string Email { get; set; } + + [JsonPropertyName("password")] + public required string Password { get; set; } + + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/seed/csharp-sdk/csharp-property-access/.github/workflows/ci.yml b/seed/csharp-sdk/csharp-property-access/.github/workflows/ci.yml new file mode 100644 index 00000000000..d7df5b9fe4c --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +name: ci + +on: [push] + +jobs: + compile: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - uses: actions/checkout@master + + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 8.x + + - name: Install tools + run: | + dotnet tool restore + + - name: Build Release + run: dotnet build src -c Release /p:ContinuousIntegrationBuild=true + + unit-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - uses: actions/checkout@master + + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 8.x + + - name: Install tools + run: | + dotnet tool restore + + - name: Run Tests + run: | + dotnet test src + + + publish: + needs: [compile] + if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v1 + with: + dotnet-version: 8.x + + - name: Publish + env: + NUGET_API_KEY: ${{ secrets.NUGET_API_TOKEN }} + run: | + dotnet pack src -c Release + dotnet nuget push src/SeedCsharpAccess/bin/Release/*.nupkg --api-key $NUGET_API_KEY --source "nuget.org" diff --git a/seed/csharp-sdk/csharp-property-access/.gitignore b/seed/csharp-sdk/csharp-property-access/.gitignore new file mode 100644 index 00000000000..11014f2b33d --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/.gitignore @@ -0,0 +1,484 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. + +## This is based on `dotnet new gitignore` and customized by Fern + +# dotenv files +.env + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +# [Rr]elease/ (Ignored by Fern) +# [Rr]eleases/ (Ignored by Fern) +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +# [Ll]og/ (Ignored by Fern) +# [Ll]ogs/ (Ignored by Fern) + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET +project.lock.json +project.fragment.lock.json +artifacts/ + +# Tye +.tye/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml +.idea + +## +## Visual studio for Mac +## + + +# globs +Makefile.in +*.userprefs +*.usertasks +config.make +config.status +aclocal.m4 +install-sh +autom4te.cache/ +*.tar.gz +tarballs/ +test-results/ + +# Mac bundle stuff +*.dmg +*.app + +# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Vim temporary swap files +*.swp diff --git a/seed/csharp-sdk/csharp-property-access/.mock/definition/__package__.yml b/seed/csharp-sdk/csharp-property-access/.mock/definition/__package__.yml new file mode 100644 index 00000000000..163885d207e --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/.mock/definition/__package__.yml @@ -0,0 +1,21 @@ +types: + User: + properties: + id: + type: string + access: read-only + name: string + email: string + password: + type: string + access: write-only + +service: + auth: false + base-path: /users + endpoints: + createUser: + method: POST + path: "" + request: User + response: User \ No newline at end of file diff --git a/seed/csharp-sdk/csharp-property-access/.mock/definition/api.yml b/seed/csharp-sdk/csharp-property-access/.mock/definition/api.yml new file mode 100644 index 00000000000..773f9b4ea27 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/.mock/definition/api.yml @@ -0,0 +1 @@ +name: csharp-access diff --git a/seed/csharp-sdk/csharp-property-access/.mock/fern.config.json b/seed/csharp-sdk/csharp-property-access/.mock/fern.config.json new file mode 100644 index 00000000000..4c8e54ac313 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/.mock/fern.config.json @@ -0,0 +1 @@ +{"organization": "fern-test", "version": "*"} \ No newline at end of file diff --git a/seed/csharp-sdk/csharp-property-access/.mock/generators.yml b/seed/csharp-sdk/csharp-property-access/.mock/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/.mock/generators.yml @@ -0,0 +1 @@ +{} diff --git a/seed/csharp-sdk/csharp-property-access/README.md b/seed/csharp-sdk/csharp-property-access/README.md new file mode 100644 index 00000000000..759530aba94 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/README.md @@ -0,0 +1,95 @@ +# Seed C# Library + +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=Seed%2FC%23) +[![nuget shield](https://img.shields.io/nuget/v/SeedCsharpAccess)](https://nuget.org/packages/SeedCsharpAccess) + +The Seed C# library provides convenient access to the Seed API from C#. + +## Installation + +```sh +nuget install SeedCsharpAccess +``` + +## Usage + +Instantiate and use the client with the following: + +```csharp +using SeedCsharpAccess; + +var client = new SeedCsharpAccessClient(); +await client.CreateUserAsync( + new User + { + Id = "id", + Name = "name", + Email = "email", + Password = "password", + } +); +``` + +## Exception Handling + +When the API returns a non-success status code (4xx or 5xx response), a subclass of the following error +will be thrown. + +```csharp +using SeedCsharpAccess; + +try { + var response = await client.CreateUserAsync(...); +} catch (SeedCsharpAccessApiException e) { + System.Console.WriteLine(e.Body); + System.Console.WriteLine(e.StatusCode); +} +``` + +## Advanced + +### Retries + +The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long +as the request is deemed retryable and the number of retry attempts has not grown larger than the configured +retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `MaxRetries` request option to configure this behavior. + +```csharp +var response = await client.CreateUserAsync( + ..., + new RequestOptions { + MaxRetries: 0 // Override MaxRetries at the request level + } +); +``` + +### Timeouts + +The SDK defaults to a 30 second timeout. Use the `Timeout` option to configure this behavior. + +```csharp +var response = await client.CreateUserAsync( + ..., + new RequestOptions { + Timeout: TimeSpan.FromSeconds(3) // Override timeout to 3s + } +); +``` + +## Contributing + +While we value open-source contributions to this SDK, this library is generated programmatically. +Additions made directly to this library would have to be moved over to our generation code, +otherwise they would be overwritten upon the next generated release. Feel free to open a PR as +a proof of concept, but know that we will not be able to merge it as-is. We suggest opening +an issue first to discuss with us! + +On the other hand, contributions to the README are always very welcome! \ No newline at end of file diff --git a/seed/csharp-sdk/csharp-property-access/reference.md b/seed/csharp-sdk/csharp-property-access/reference.md new file mode 100644 index 00000000000..8ea0c5b55e1 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/reference.md @@ -0,0 +1,48 @@ +# Reference +
client.CreateUserAsync(User { ... }) -> User +
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```csharp +await client.CreateUserAsync( + new User + { + Id = "id", + Name = "name", + Email = "email", + Password = "password", + } +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**request:** `User` + +
+
+
+
+ + +
+
+
diff --git a/seed/csharp-sdk/csharp-property-access/snippet-templates.json b/seed/csharp-sdk/csharp-property-access/snippet-templates.json new file mode 100644 index 00000000000..e69de29bb2d diff --git a/seed/csharp-sdk/csharp-property-access/snippet.json b/seed/csharp-sdk/csharp-property-access/snippet.json new file mode 100644 index 00000000000..9b523ca0bf1 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/snippet.json @@ -0,0 +1,17 @@ +{ + "types": {}, + "endpoints": [ + { + "example_identifier": null, + "id": { + "path": "/users", + "method": "POST", + "identifier_override": "endpoint_.createUser" + }, + "snippet": { + "type": "csharp", + "client": "using SeedCsharpAccess;\n\nvar client = new SeedCsharpAccessClient();\nawait client.CreateUserAsync(\n new User\n {\n Id = \"id\",\n Name = \"name\",\n Email = \"email\",\n Password = \"password\",\n }\n);\n" + } + } + ] +} \ No newline at end of file diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateOnlyJsonTests.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateOnlyJsonTests.cs new file mode 100644 index 00000000000..0020a414f84 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateOnlyJsonTests.cs @@ -0,0 +1,76 @@ +using NUnit.Framework; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess.Test.Core.Json; + +[TestFixture] +public class DateOnlyJsonTests +{ + [Test] + public void SerializeDateOnly_ShouldMatchExpectedFormat() + { + (DateOnly dateOnly, string expected)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (new DateOnly(2023, 1, 1), "\"2023-01-01\""), + (new DateOnly(2023, 12, 31), "\"2023-12-31\""), + (new DateOnly(2023, 6, 15), "\"2023-06-15\""), + (new DateOnly(2023, 3, 10), "\"2023-03-10\""), + ]; + foreach (var (dateOnly, expected) in testCases) + { + var json = JsonUtils.Serialize(dateOnly); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeDateOnly_ShouldMatchExpectedDateOnly() + { + (DateOnly expected, string json)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (new DateOnly(2023, 1, 1), "\"2023-01-01\""), + (new DateOnly(2023, 12, 31), "\"2023-12-31\""), + (new DateOnly(2023, 6, 15), "\"2023-06-15\""), + (new DateOnly(2023, 3, 10), "\"2023-03-10\""), + ]; + + foreach (var (expected, json) in testCases) + { + var dateOnly = JsonUtils.Deserialize(json); + Assert.That(dateOnly, Is.EqualTo(expected)); + } + } + + [Test] + public void SerializeNullableDateOnly_ShouldMatchExpectedFormat() + { + (DateOnly? dateOnly, string expected)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (null, "null"), + ]; + foreach (var (dateOnly, expected) in testCases) + { + var json = JsonUtils.Serialize(dateOnly); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeNullableDateOnly_ShouldMatchExpectedDateOnly() + { + (DateOnly? expected, string json)[] testCases = + [ + (new DateOnly(2023, 10, 5), "\"2023-10-05\""), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateOnly = JsonUtils.Deserialize(json); + Assert.That(dateOnly, Is.EqualTo(expected)); + } + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateTimeJsonTests.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateTimeJsonTests.cs new file mode 100644 index 00000000000..62965eb7eb0 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/DateTimeJsonTests.cs @@ -0,0 +1,110 @@ +using NUnit.Framework; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess.Test.Core.Json; + +[TestFixture] +public class DateTimeJsonTests +{ + [Test] + public void SerializeDateTime_ShouldMatchExpectedFormat() + { + (DateTime dateTime, string expected)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), "\"2023-01-01T00:00:00.000Z\""), + ( + new DateTime(2023, 12, 31, 23, 59, 59, DateTimeKind.Utc), + "\"2023-12-31T23:59:59.000Z\"" + ), + (new DateTime(2023, 6, 15, 12, 0, 0, DateTimeKind.Utc), "\"2023-06-15T12:00:00.000Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.000Z\"" + ), + ( + new DateTime(2023, 3, 10, 8, 45, 30, 123, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.123Z\"" + ), + ]; + foreach (var (dateTime, expected) in testCases) + { + var json = JsonUtils.Serialize(dateTime); + Assert.That(json, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeDateTime_ShouldMatchExpectedDateTime() + { + (DateTime expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc), "\"2023-01-01T00:00:00.000Z\""), + ( + new DateTime(2023, 12, 31, 23, 59, 59, DateTimeKind.Utc), + "\"2023-12-31T23:59:59.000Z\"" + ), + (new DateTime(2023, 6, 15, 12, 0, 0, DateTimeKind.Utc), "\"2023-06-15T12:00:00.000Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.000Z\"" + ), + (new DateTime(2023, 3, 10, 8, 45, 30, DateTimeKind.Utc), "\"2023-03-10T08:45:30Z\""), + ( + new DateTime(2023, 3, 10, 8, 45, 30, 123, DateTimeKind.Utc), + "\"2023-03-10T08:45:30.123Z\"" + ), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } + + [Test] + public void SerializeNullableDateTime_ShouldMatchExpectedFormat() + { + (DateTime? expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } + + [Test] + public void DeserializeNullableDateTime_ShouldMatchExpectedDateTime() + { + (DateTime? expected, string json)[] testCases = + [ + ( + new DateTime(2023, 10, 5, 14, 30, 0, DateTimeKind.Utc), + "\"2023-10-05T14:30:00.000Z\"" + ), + (null, "null"), + ]; + + foreach (var (expected, json) in testCases) + { + var dateTime = JsonUtils.Deserialize(json); + Assert.That(dateTime, Is.EqualTo(expected)); + } + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/EnumSerializerTests.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/EnumSerializerTests.cs new file mode 100644 index 00000000000..089b912a07e --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/EnumSerializerTests.cs @@ -0,0 +1,60 @@ +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; +using NUnit.Framework; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess.Test.Core.Json; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class StringEnumSerializerTests +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + private const DummyEnum KnownEnumValue2 = DummyEnum.KnownValue2; + private const string KnownEnumValue2String = "known_value2"; + + private const string JsonWithKnownEnum2 = $$""" + { + "enum_property": "{{KnownEnumValue2String}}" + } + """; + + [Test] + public void ShouldParseKnownEnumValue2() + { + var obj = JsonSerializer.Deserialize(JsonWithKnownEnum2, JsonOptions); + Assert.That(obj, Is.Not.Null); + Assert.That(obj.EnumProperty, Is.EqualTo(KnownEnumValue2)); + } + + [Test] + public void ShouldSerializeKnownEnumValue2() + { + var json = JsonSerializer.SerializeToElement( + new DummyObject { EnumProperty = KnownEnumValue2 }, + JsonOptions + ); + TestContext.Out.WriteLine("Serialized JSON: \n" + json); + var enumString = json.GetProperty("enum_property").GetString(); + Assert.That(enumString, Is.Not.Null); + Assert.That(enumString, Is.EqualTo(KnownEnumValue2String)); + } +} + +public class DummyObject +{ + [JsonPropertyName("enum_property")] + public DummyEnum EnumProperty { get; set; } +} + +[JsonConverter(typeof(EnumSerializer))] +public enum DummyEnum +{ + [EnumMember(Value = "known_value1")] + KnownValue1, + + [EnumMember(Value = "known_value2")] + KnownValue2, +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/OneOfSerializerTests.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/OneOfSerializerTests.cs new file mode 100644 index 00000000000..27ed85cb52a --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/Json/OneOfSerializerTests.cs @@ -0,0 +1,311 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using NUnit.Framework; +using OneOf; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess.Test.Core; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class OneOfSerializerTests +{ + private class Foo + { + [JsonPropertyName("string_prop")] + public required string StringProp { get; set; } + } + + private class Bar + { + [JsonPropertyName("int_prop")] + public required int IntProp { get; set; } + } + + private static readonly OneOf OneOf1 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT2(new { }); + private const string OneOf1String = "{}"; + + private static readonly OneOf OneOf2 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT0("test"); + private const string OneOf2String = "\"test\""; + + private static readonly OneOf OneOf3 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT1(123); + private const string OneOf3String = "123"; + + private static readonly OneOf OneOf4 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT3(new Foo { StringProp = "test" }); + private const string OneOf4String = "{\n \"string_prop\": \"test\"\n}"; + + private static readonly OneOf OneOf5 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT4(new Bar { IntProp = 5 }); + private const string OneOf5String = "{\n \"int_prop\": 5\n}"; + + [Test] + public void Serialize_OneOfs_Should_Return_Expected_String() + { + (OneOf, string)[] testData = + [ + (OneOf1, OneOf1String), + (OneOf2, OneOf2String), + (OneOf3, OneOf3String), + (OneOf4, OneOf4String), + (OneOf5, OneOf5String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected)); + } + }); + } + + [Test] + public void OneOfs_Should_Deserialize_From_String() + { + (OneOf, string)[] testData = + [ + (OneOf1, OneOf1String), + (OneOf2, OneOf2String), + (OneOf3, OneOf3String), + (OneOf4, OneOf4String), + (OneOf5, OneOf5String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize>(json); + Assert.That(result.Index, Is.EqualTo(oneOf.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result.Value))); + } + }); + } + + private static readonly OneOf? NullableOneOf1 = null; + private const string NullableOneOf1String = "null"; + + private static readonly OneOf? NullableOneOf2 = OneOf< + string, + int, + object, + Foo, + Bar + >.FromT4(new Bar { IntProp = 5 }); + private const string NullableOneOf2String = "{\n \"int_prop\": 5\n}"; + + [Test] + public void Serialize_NullableOneOfs_Should_Return_Expected_String() + { + (OneOf?, string)[] testData = + [ + (NullableOneOf1, NullableOneOf1String), + (NullableOneOf2, NullableOneOf2String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected)); + } + }); + } + + [Test] + public void NullableOneOfs_Should_Deserialize_From_String() + { + (OneOf?, string)[] testData = + [ + (NullableOneOf1, NullableOneOf1String), + (NullableOneOf2, NullableOneOf2String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize?>(json); + Assert.That(result?.Index, Is.EqualTo(oneOf?.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result?.Value))); + } + }); + } + + private static readonly OneOf OneOfWithNullable1 = OneOf< + string, + int, + Foo? + >.FromT2(null); + private const string OneOfWithNullable1String = "null"; + + private static readonly OneOf OneOfWithNullable2 = OneOf< + string, + int, + Foo? + >.FromT2(new Foo { StringProp = "test" }); + private const string OneOfWithNullable2String = "{\n \"string_prop\": \"test\"\n}"; + + private static readonly OneOf OneOfWithNullable3 = OneOf< + string, + int, + Foo? + >.FromT0("test"); + private const string OneOfWithNullable3String = "\"test\""; + + [Test] + public void Serialize_OneOfWithNullables_Should_Return_Expected_String() + { + (OneOf, string)[] testData = + [ + (OneOfWithNullable1, OneOfWithNullable1String), + (OneOfWithNullable2, OneOfWithNullable2String), + (OneOfWithNullable3, OneOfWithNullable3String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, expected) in testData) + { + var result = JsonUtils.Serialize(oneOf); + Assert.That(result, Is.EqualTo(expected)); + } + }); + } + + [Test] + public void OneOfWithNullables_Should_Deserialize_From_String() + { + (OneOf, string)[] testData = + [ + // (OneOfWithNullable1, OneOfWithNullable1String), // not possible with .NET's JSON serializer + (OneOfWithNullable2, OneOfWithNullable2String), + (OneOfWithNullable3, OneOfWithNullable3String), + ]; + Assert.Multiple(() => + { + foreach (var (oneOf, json) in testData) + { + var result = JsonUtils.Deserialize>(json); + Assert.That(result.Index, Is.EqualTo(oneOf.Index)); + Assert.That(json, Is.EqualTo(JsonUtils.Serialize(result.Value))); + } + }); + } + + [Test] + public void Serialize_OneOfWithObjectLast_Should_Return_Expected_String() + { + var oneOfWithObjectLast = OneOf.FromT4( + new { random = "data" } + ); + const string oneOfWithObjectLastString = "{\n \"random\": \"data\"\n}"; + + var result = JsonUtils.Serialize(oneOfWithObjectLast); + Assert.That(result, Is.EqualTo(oneOfWithObjectLastString)); + } + + [Test] + public void OneOfWithObjectLast_Should_Deserialize_From_String() + { + const string oneOfWithObjectLastString = "{\n \"random\": \"data\"\n}"; + var result = JsonUtils.Deserialize>( + oneOfWithObjectLastString + ); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(4)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That(JsonUtils.Serialize(result.Value), Is.EqualTo(oneOfWithObjectLastString)); + }); + } + + [Test] + public void Serialize_OneOfWithObjectNotLast_Should_Return_Expected_String() + { + var oneOfWithObjectNotLast = OneOf.FromT1( + new { random = "data" } + ); + const string oneOfWithObjectNotLastString = "{\n \"random\": \"data\"\n}"; + + var result = JsonUtils.Serialize(oneOfWithObjectNotLast); + Assert.That(result, Is.EqualTo(oneOfWithObjectNotLastString)); + } + + [Test] + public void OneOfWithObjectNotLast_Should_Deserialize_From_String() + { + const string oneOfWithObjectNotLastString = "{\n \"random\": \"data\"\n}"; + var result = JsonUtils.Deserialize>( + oneOfWithObjectNotLastString + ); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(1)); + Assert.That(result.Value, Is.InstanceOf()); + Assert.That( + JsonUtils.Serialize(result.Value), + Is.EqualTo(oneOfWithObjectNotLastString) + ); + }); + } + + [Test] + public void Serialize_OneOfSingleType_Should_Return_Expected_String() + { + var oneOfSingle = OneOf.FromT0("single"); + const string oneOfSingleString = "\"single\""; + + var result = JsonUtils.Serialize(oneOfSingle); + Assert.That(result, Is.EqualTo(oneOfSingleString)); + } + + [Test] + public void OneOfSingleType_Should_Deserialize_From_String() + { + const string oneOfSingleString = "\"single\""; + var result = JsonUtils.Deserialize>(oneOfSingleString); + Assert.Multiple(() => + { + Assert.That(result.Index, Is.EqualTo(0)); + Assert.That(result.Value, Is.EqualTo("single")); + }); + } + + [Test] + public void Deserialize_InvalidData_Should_Throw_Exception() + { + const string invalidJson = "{\"invalid\": \"data\"}"; + + Assert.Throws(() => + { + JsonUtils.Deserialize>(invalidJson); + }); + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/RawClientTests.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/RawClientTests.cs new file mode 100644 index 00000000000..93c7d8c205f --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Core/RawClientTests.cs @@ -0,0 +1,109 @@ +using NUnit.Framework; +using SeedCsharpAccess.Core; +using WireMock.Server; +using SystemTask = System.Threading.Tasks.Task; +using WireMockRequest = WireMock.RequestBuilders.Request; +using WireMockResponse = WireMock.ResponseBuilders.Response; + +namespace SeedCsharpAccess.Test.Core; + +[TestFixture] +[Parallelizable(ParallelScope.Self)] +public class RawClientTests +{ + private const int MaxRetries = 3; + private WireMockServer _server; + private HttpClient _httpClient; + private RawClient _rawClient; + private string _baseUrl; + + [SetUp] + public void SetUp() + { + _server = WireMockServer.Start(); + _baseUrl = _server.Url ?? ""; + _httpClient = new HttpClient { BaseAddress = new Uri(_baseUrl) }; + _rawClient = new RawClient( + new ClientOptions { HttpClient = _httpClient, MaxRetries = MaxRetries } + ) + { + BaseRetryDelay = 0, + }; + } + + [Test] + [TestCase(408)] + [TestCase(429)] + [TestCase(500)] + [TestCase(504)] + public async SystemTask MakeRequestAsync_ShouldRetry_OnRetryableStatusCodes(int statusCode) + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(statusCode)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WhenStateIs("Server Error") + .WillSetStateTo("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(statusCode)); + + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WhenStateIs("Success") + .RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success")); + + var request = new RawClient.BaseApiRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.MakeRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(200)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Success")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(MaxRetries)); + } + + [Test] + [TestCase(400)] + [TestCase(409)] + public async SystemTask MakeRequestAsync_ShouldRetry_OnNonRetryableStatusCodes(int statusCode) + { + _server + .Given(WireMockRequest.Create().WithPath("/test").UsingGet()) + .InScenario("Retry") + .WillSetStateTo("Server Error") + .RespondWith(WireMockResponse.Create().WithStatusCode(statusCode).WithBody("Failure")); + + var request = new RawClient.BaseApiRequest + { + BaseUrl = _baseUrl, + Method = HttpMethod.Get, + Path = "/test", + }; + + var response = await _rawClient.MakeRequestAsync(request); + Assert.That(response.StatusCode, Is.EqualTo(statusCode)); + + var content = await response.Raw.Content.ReadAsStringAsync(); + Assert.That(content, Is.EqualTo("Failure")); + + Assert.That(_server.LogEntries.Count, Is.EqualTo(1)); + } + + [TearDown] + public void TearDown() + { + _server.Dispose(); + _httpClient.Dispose(); + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.Custom.props b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.Custom.props new file mode 100644 index 00000000000..55e683b0772 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.Custom.props @@ -0,0 +1,7 @@ + + + \ No newline at end of file diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.csproj b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.csproj new file mode 100644 index 00000000000..3b75989d4c4 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/SeedCsharpAccess.Test.csproj @@ -0,0 +1,38 @@ + + + + net8.0 + 12 + enable + enable + false + true + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + \ No newline at end of file diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/TestClient.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/TestClient.cs new file mode 100644 index 00000000000..3a5dd369146 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/TestClient.cs @@ -0,0 +1,6 @@ +using NUnit.Framework; + +namespace SeedCsharpAccess.Test; + +[TestFixture] +public class TestClient; diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Unit/MockServer/BaseMockServerTest.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Unit/MockServer/BaseMockServerTest.cs new file mode 100644 index 00000000000..0263b45d280 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Unit/MockServer/BaseMockServerTest.cs @@ -0,0 +1,38 @@ +using NUnit.Framework; +using SeedCsharpAccess; +using WireMock.Logging; +using WireMock.Server; +using WireMock.Settings; + +namespace SeedCsharpAccess.Test.Unit.MockServer; + +[SetUpFixture] +public class BaseMockServerTest +{ + protected static WireMockServer Server { get; set; } = null!; + + protected static SeedCsharpAccessClient Client { get; set; } = null!; + + protected static RequestOptions RequestOptions { get; set; } = null!; + + [OneTimeSetUp] + public void GlobalSetup() + { + // Start the WireMock server + Server = WireMockServer.Start( + new WireMockServerSettings { Logger = new WireMockConsoleLogger() } + ); + + // Initialize the Client + Client = new SeedCsharpAccessClient(); + + RequestOptions = new RequestOptions { BaseUrl = Server.Urls[0] }; + } + + [OneTimeTearDown] + public void GlobalTeardown() + { + Server.Stop(); + Server.Dispose(); + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Unit/MockServer/CreateUserTest.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Unit/MockServer/CreateUserTest.cs new file mode 100644 index 00000000000..cf5389e74be --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess.Test/Unit/MockServer/CreateUserTest.cs @@ -0,0 +1,64 @@ +using FluentAssertions.Json; +using global::System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using SeedCsharpAccess; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess.Test.Unit.MockServer; + +[TestFixture] +public class CreateUserTest : BaseMockServerTest +{ + [Test] + public async global::System.Threading.Tasks.Task MockServerTest() + { + const string requestJson = """ + { + "id": "id", + "name": "name", + "email": "email", + "password": "password" + } + """; + + const string mockResponse = """ + { + "id": "id", + "name": "name", + "email": "email", + "password": "password" + } + """; + + Server + .Given( + WireMock + .RequestBuilders.Request.Create() + .WithPath("/users") + .UsingPost() + .WithBodyAsJson(requestJson) + ) + .RespondWith( + WireMock + .ResponseBuilders.Response.Create() + .WithStatusCode(200) + .WithBody(mockResponse) + ); + + var response = await Client.CreateUserAsync( + new User + { + Id = "id", + Name = "name", + Email = "email", + Password = "password", + }, + RequestOptions + ); + JToken + .Parse(mockResponse) + .Should() + .BeEquivalentTo(JToken.Parse(JsonUtils.Serialize(response))); + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/CollectionItemSerializer.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/CollectionItemSerializer.cs new file mode 100644 index 00000000000..a4ca9c17ec7 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/CollectionItemSerializer.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCsharpAccess.Core; + +/// +/// Json collection converter. +/// +/// Type of item to convert. +/// Converter to use for individual items. +internal class CollectionItemSerializer + : JsonConverter> + where TConverterType : JsonConverter +{ + /// + /// Reads a json string and deserializes it into an object. + /// + /// Json reader. + /// Type to convert. + /// Serializer options. + /// Created object. + public override IEnumerable? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType == JsonTokenType.Null) + { + return default; + } + + var jsonSerializerOptions = new JsonSerializerOptions(options); + jsonSerializerOptions.Converters.Clear(); + jsonSerializerOptions.Converters.Add(Activator.CreateInstance()); + + var returnValue = new List(); + + while (reader.TokenType != JsonTokenType.EndArray) + { + if (reader.TokenType != JsonTokenType.StartArray) + { + var item = (TDatatype)( + JsonSerializer.Deserialize(ref reader, typeof(TDatatype), jsonSerializerOptions) + ?? throw new Exception( + $"Failed to deserialize collection item of type {typeof(TDatatype)}" + ) + ); + returnValue.Add(item); + } + + reader.Read(); + } + + return returnValue; + } + + /// + /// Writes a json string. + /// + /// Json writer. + /// Value to write. + /// Serializer options. + public override void Write( + Utf8JsonWriter writer, + IEnumerable? value, + JsonSerializerOptions options + ) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + + var jsonSerializerOptions = new JsonSerializerOptions(options); + jsonSerializerOptions.Converters.Clear(); + jsonSerializerOptions.Converters.Add(Activator.CreateInstance()); + + writer.WriteStartArray(); + + foreach (var data in value) + { + JsonSerializer.Serialize(writer, data, jsonSerializerOptions); + } + + writer.WriteEndArray(); + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Constants.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Constants.cs new file mode 100644 index 00000000000..410015c69b3 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Constants.cs @@ -0,0 +1,7 @@ +namespace SeedCsharpAccess.Core; + +internal static class Constants +{ + public const string DateTimeFormat = "yyyy'-'MM'-'dd'T'HH':'mm':'ss.fffK"; + public const string DateFormat = "yyyy-MM-dd"; +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/DateOnlyConverter.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/DateOnlyConverter.cs new file mode 100644 index 00000000000..ea6af4b07f7 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/DateOnlyConverter.cs @@ -0,0 +1,747 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// ReSharper disable All + +using global::System.Diagnostics; +using global::System.Diagnostics.CodeAnalysis; +using global::System.Globalization; +using global::System.Runtime.CompilerServices; +using global::System.Runtime.InteropServices; +using global::System.Text.Json; +using global::System.Text.Json.Serialization; + +// ReSharper disable SuggestVarOrType_SimpleTypes +// ReSharper disable SuggestVarOrType_BuiltInTypes + +namespace SeedCsharpAccess.Core +{ + /// + /// Custom converter for handling the data type with the System.Text.Json library. + /// + /// + /// This class backported from: + /// + /// System.Text.Json.Serialization.Converters.DateOnlyConverter + /// + public sealed class DateOnlyConverter : JsonConverter + { + private const int FormatLength = 10; // YYYY-MM-DD + + private const int MaxEscapedFormatLength = + FormatLength * JsonConstants.MaxExpansionFactorWhileEscaping; + + /// + public override DateOnly Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType != JsonTokenType.String) + { + ThrowHelper.ThrowInvalidOperationException_ExpectedString(reader.TokenType); + } + + return ReadCore(ref reader); + } + + /// + public override DateOnly ReadAsPropertyName( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + Debug.Assert(reader.TokenType == JsonTokenType.PropertyName); + return ReadCore(ref reader); + } + + private static DateOnly ReadCore(ref Utf8JsonReader reader) + { + if ( + !JsonHelpers.IsInRangeInclusive( + reader.ValueLength(), + FormatLength, + MaxEscapedFormatLength + ) + ) + { + ThrowHelper.ThrowFormatException(DataType.DateOnly); + } + + scoped ReadOnlySpan source; + if (!reader.HasValueSequence && !reader.ValueIsEscaped) + { + source = reader.ValueSpan; + } + else + { + Span stackSpan = stackalloc byte[MaxEscapedFormatLength]; + int bytesWritten = reader.CopyString(stackSpan); + source = stackSpan.Slice(0, bytesWritten); + } + + if (!JsonHelpers.TryParseAsIso(source, out DateOnly value)) + { + ThrowHelper.ThrowFormatException(DataType.DateOnly); + } + + return value; + } + + /// + public override void Write( + Utf8JsonWriter writer, + DateOnly value, + JsonSerializerOptions options + ) + { +#if NET8_0_OR_GREATER + Span buffer = stackalloc byte[FormatLength]; +#else + Span buffer = stackalloc char[FormatLength]; +#endif + // ReSharper disable once RedundantAssignment + bool formattedSuccessfully = value.TryFormat( + buffer, + out int charsWritten, + "O".AsSpan(), + CultureInfo.InvariantCulture + ); + Debug.Assert(formattedSuccessfully && charsWritten == FormatLength); + writer.WriteStringValue(buffer); + } + + /// + public override void WriteAsPropertyName( + Utf8JsonWriter writer, + DateOnly value, + JsonSerializerOptions options + ) + { +#if NET8_0_OR_GREATER + Span buffer = stackalloc byte[FormatLength]; +#else + Span buffer = stackalloc char[FormatLength]; +#endif + // ReSharper disable once RedundantAssignment + bool formattedSuccessfully = value.TryFormat( + buffer, + out int charsWritten, + "O".AsSpan(), + CultureInfo.InvariantCulture + ); + Debug.Assert(formattedSuccessfully && charsWritten == FormatLength); + writer.WritePropertyName(buffer); + } + } + + internal static class JsonConstants + { + // The maximum number of fraction digits the Json DateTime parser allows + public const int DateTimeParseNumFractionDigits = 16; + + // In the worst case, an ASCII character represented as a single utf-8 byte could expand 6x when escaped. + public const int MaxExpansionFactorWhileEscaping = 6; + + // The largest fraction expressible by TimeSpan and DateTime formats + public const int MaxDateTimeFraction = 9_999_999; + + // TimeSpan and DateTime formats allow exactly up to many digits for specifying the fraction after the seconds. + public const int DateTimeNumFractionDigits = 7; + + public const byte UtcOffsetToken = (byte)'Z'; + + public const byte TimePrefix = (byte)'T'; + + public const byte Period = (byte)'.'; + + public const byte Hyphen = (byte)'-'; + + public const byte Colon = (byte)':'; + + public const byte Plus = (byte)'+'; + } + + // ReSharper disable SuggestVarOrType_Elsewhere + // ReSharper disable SuggestVarOrType_SimpleTypes + // ReSharper disable SuggestVarOrType_BuiltInTypes + + + internal static class JsonHelpers + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool IsInRangeInclusive(int value, int lowerBound, int upperBound) => + (uint)(value - lowerBound) <= (uint)(upperBound - lowerBound); + + public static bool IsDigit(byte value) => (uint)(value - '0') <= '9' - '0'; + + [StructLayout(LayoutKind.Auto)] + private struct DateTimeParseData + { + public int Year; + public int Month; + public int Day; + public bool IsCalendarDateOnly; + public int Hour; + public int Minute; + public int Second; + public int Fraction; // This value should never be greater than 9_999_999. + public int OffsetHours; + public int OffsetMinutes; + + // ReSharper disable once NotAccessedField.Local + public byte OffsetToken; + } + + public static bool TryParseAsIso(ReadOnlySpan source, out DateOnly value) + { + if ( + TryParseDateTimeOffset(source, out DateTimeParseData parseData) + && parseData.IsCalendarDateOnly + && TryCreateDateTime(parseData, DateTimeKind.Unspecified, out DateTime dateTime) + ) + { + value = DateOnly.FromDateTime(dateTime); + return true; + } + + value = default; + return false; + } + + /// + /// ISO 8601 date time parser (ISO 8601-1:2019). + /// + /// The date/time to parse in UTF-8 format. + /// The parsed for the given . + /// + /// Supports extended calendar date (5.2.2.1) and complete (5.4.2.1) calendar date/time of day + /// representations with optional specification of seconds and fractional seconds. + /// + /// Times can be explicitly specified as UTC ("Z" - 5.3.3) or offsets from UTC ("+/-hh:mm" 5.3.4.2). + /// If unspecified they are considered to be local per spec. + /// + /// Examples: (TZD is either "Z" or hh:mm offset from UTC) + /// + /// YYYY-MM-DD (e.g. 1997-07-16) + /// YYYY-MM-DDThh:mm (e.g. 1997-07-16T19:20) + /// YYYY-MM-DDThh:mm:ss (e.g. 1997-07-16T19:20:30) + /// YYYY-MM-DDThh:mm:ss.s (e.g. 1997-07-16T19:20:30.45) + /// YYYY-MM-DDThh:mmTZD (e.g. 1997-07-16T19:20+01:00) + /// YYYY-MM-DDThh:mm:ssTZD (e.g. 1997-07-16T19:20:3001:00) + /// YYYY-MM-DDThh:mm:ss.sTZD (e.g. 1997-07-16T19:20:30.45Z) + /// + /// Generally speaking we always require the "extended" option when one exists (3.1.3.5). + /// The extended variants have separator characters between components ('-', ':', '.', etc.). + /// Spaces are not permitted. + /// + /// "true" if successfully parsed. + private static bool TryParseDateTimeOffset( + ReadOnlySpan source, + out DateTimeParseData parseData + ) + { + parseData = default; + + // too short datetime + Debug.Assert(source.Length >= 10); + + // Parse the calendar date + // ----------------------- + // ISO 8601-1:2019 5.2.2.1b "Calendar date complete extended format" + // [dateX] = [year]["-"][month]["-"][day] + // [year] = [YYYY] [0000 - 9999] (4.3.2) + // [month] = [MM] [01 - 12] (4.3.3) + // [day] = [DD] [01 - 28, 29, 30, 31] (4.3.4) + // + // Note: 5.2.2.2 "Representations with reduced precision" allows for + // just [year]["-"][month] (a) and just [year] (b), but we currently + // don't permit it. + + { + uint digit1 = source[0] - (uint)'0'; + uint digit2 = source[1] - (uint)'0'; + uint digit3 = source[2] - (uint)'0'; + uint digit4 = source[3] - (uint)'0'; + + if (digit1 > 9 || digit2 > 9 || digit3 > 9 || digit4 > 9) + { + return false; + } + + parseData.Year = (int)(digit1 * 1000 + digit2 * 100 + digit3 * 10 + digit4); + } + + if ( + source[4] != JsonConstants.Hyphen + || !TryGetNextTwoDigits(source.Slice(start: 5, length: 2), ref parseData.Month) + || source[7] != JsonConstants.Hyphen + || !TryGetNextTwoDigits(source.Slice(start: 8, length: 2), ref parseData.Day) + ) + { + return false; + } + + // We now have YYYY-MM-DD [dateX] + // ReSharper disable once ConvertIfStatementToSwitchStatement + if (source.Length == 10) + { + parseData.IsCalendarDateOnly = true; + return true; + } + + // Parse the time of day + // --------------------- + // + // ISO 8601-1:2019 5.3.1.2b "Local time of day complete extended format" + // [timeX] = ["T"][hour][":"][min][":"][sec] + // [hour] = [hh] [00 - 23] (4.3.8a) + // [minute] = [mm] [00 - 59] (4.3.9a) + // [sec] = [ss] [00 - 59, 60 with a leap second] (4.3.10a) + // + // ISO 8601-1:2019 5.3.3 "UTC of day" + // [timeX]["Z"] + // + // ISO 8601-1:2019 5.3.4.2 "Local time of day with the time shift between + // local timescale and UTC" (Extended format) + // + // [shiftX] = ["+"|"-"][hour][":"][min] + // + // Notes: + // + // "T" is optional per spec, but _only_ when times are used alone. In our + // case, we're reading out a complete date & time and as such require "T". + // (5.4.2.1b). + // + // For [timeX] We allow seconds to be omitted per 5.3.1.3a "Representations + // with reduced precision". 5.3.1.3b allows just specifying the hour, but + // we currently don't permit this. + // + // Decimal fractions are allowed for hours, minutes and seconds (5.3.14). + // We only allow fractions for seconds currently. Lower order components + // can't follow, i.e. you can have T23.3, but not T23.3:04. There must be + // one digit, but the max number of digits is implementation defined. We + // currently allow up to 16 digits of fractional seconds only. While we + // support 16 fractional digits we only parse the first seven, anything + // past that is considered a zero. This is to stay compatible with the + // DateTime implementation which is limited to this resolution. + + if (source.Length < 16) + { + // Source does not have enough characters for YYYY-MM-DDThh:mm + return false; + } + + // Parse THH:MM (e.g. "T10:32") + if ( + source[10] != JsonConstants.TimePrefix + || source[13] != JsonConstants.Colon + || !TryGetNextTwoDigits(source.Slice(start: 11, length: 2), ref parseData.Hour) + || !TryGetNextTwoDigits(source.Slice(start: 14, length: 2), ref parseData.Minute) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm + Debug.Assert(source.Length >= 16); + if (source.Length == 16) + { + return true; + } + + byte curByte = source[16]; + int sourceIndex = 17; + + // Either a TZD ['Z'|'+'|'-'] or a seconds separator [':'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + case JsonConstants.Colon: + break; + default: + return false; + } + + // Try reading the seconds + if ( + source.Length < 19 + || !TryGetNextTwoDigits(source.Slice(start: 17, length: 2), ref parseData.Second) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm:ss + Debug.Assert(source.Length >= 19); + if (source.Length == 19) + { + return true; + } + + curByte = source[19]; + sourceIndex = 20; + + // Either a TZD ['Z'|'+'|'-'] or a seconds decimal fraction separator ['.'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + case JsonConstants.Period: + break; + default: + return false; + } + + // Source does not have enough characters for second fractions (i.e. ".s") + // YYYY-MM-DDThh:mm:ss.s + if (source.Length < 21) + { + return false; + } + + // Parse fraction. This value should never be greater than 9_999_999 + int numDigitsRead = 0; + int fractionEnd = Math.Min( + sourceIndex + JsonConstants.DateTimeParseNumFractionDigits, + source.Length + ); + + while (sourceIndex < fractionEnd && IsDigit(curByte = source[sourceIndex])) + { + if (numDigitsRead < JsonConstants.DateTimeNumFractionDigits) + { + parseData.Fraction = parseData.Fraction * 10 + (int)(curByte - (uint)'0'); + numDigitsRead++; + } + + sourceIndex++; + } + + if (parseData.Fraction != 0) + { + while (numDigitsRead < JsonConstants.DateTimeNumFractionDigits) + { + parseData.Fraction *= 10; + numDigitsRead++; + } + } + + // We now have YYYY-MM-DDThh:mm:ss.s + Debug.Assert(sourceIndex <= source.Length); + if (sourceIndex == source.Length) + { + return true; + } + + curByte = source[sourceIndex++]; + + // TZD ['Z'|'+'|'-'] is valid at this point + switch (curByte) + { + case JsonConstants.UtcOffsetToken: + parseData.OffsetToken = JsonConstants.UtcOffsetToken; + return sourceIndex == source.Length; + case JsonConstants.Plus: + case JsonConstants.Hyphen: + parseData.OffsetToken = curByte; + return ParseOffset(ref parseData, source.Slice(sourceIndex)); + default: + return false; + } + + static bool ParseOffset(ref DateTimeParseData parseData, ReadOnlySpan offsetData) + { + // Parse the hours for the offset + if ( + offsetData.Length < 2 + || !TryGetNextTwoDigits(offsetData.Slice(0, 2), ref parseData.OffsetHours) + ) + { + return false; + } + + // We now have YYYY-MM-DDThh:mm:ss.s+|-hh + + if (offsetData.Length == 2) + { + // Just hours offset specified + return true; + } + + // Ensure we have enough for ":mm" + return offsetData.Length == 5 + && offsetData[2] == JsonConstants.Colon + && TryGetNextTwoDigits(offsetData.Slice(3), ref parseData.OffsetMinutes); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + // ReSharper disable once RedundantAssignment + private static bool TryGetNextTwoDigits(ReadOnlySpan source, ref int value) + { + Debug.Assert(source.Length == 2); + + uint digit1 = source[0] - (uint)'0'; + uint digit2 = source[1] - (uint)'0'; + + if (digit1 > 9 || digit2 > 9) + { + value = 0; + return false; + } + + value = (int)(digit1 * 10 + digit2); + return true; + } + + // The following methods are borrowed verbatim from src/Common/src/CoreLib/System/Buffers/Text/Utf8Parser/Utf8Parser.Date.Helpers.cs + + /// + /// Overflow-safe DateTime factory. + /// + private static bool TryCreateDateTime( + DateTimeParseData parseData, + DateTimeKind kind, + out DateTime value + ) + { + if (parseData.Year == 0) + { + value = default; + return false; + } + + Debug.Assert(parseData.Year <= 9999); // All of our callers to date parse the year from fixed 4-digit fields so this value is trusted. + + if ((uint)parseData.Month - 1 >= 12) + { + value = default; + return false; + } + + uint dayMinusOne = (uint)parseData.Day - 1; + if ( + dayMinusOne >= 28 + && dayMinusOne >= DateTime.DaysInMonth(parseData.Year, parseData.Month) + ) + { + value = default; + return false; + } + + if ((uint)parseData.Hour > 23) + { + value = default; + return false; + } + + if ((uint)parseData.Minute > 59) + { + value = default; + return false; + } + + // This needs to allow leap seconds when appropriate. + // See https://github.com/dotnet/runtime/issues/30135. + if ((uint)parseData.Second > 59) + { + value = default; + return false; + } + + Debug.Assert(parseData.Fraction is >= 0 and <= JsonConstants.MaxDateTimeFraction); // All of our callers to date parse the fraction from fixed 7-digit fields so this value is trusted. + + ReadOnlySpan days = DateTime.IsLeapYear(parseData.Year) + ? DaysToMonth366 + : DaysToMonth365; + int yearMinusOne = parseData.Year - 1; + int totalDays = + yearMinusOne * 365 + + yearMinusOne / 4 + - yearMinusOne / 100 + + yearMinusOne / 400 + + days[parseData.Month - 1] + + parseData.Day + - 1; + long ticks = totalDays * TimeSpan.TicksPerDay; + int totalSeconds = parseData.Hour * 3600 + parseData.Minute * 60 + parseData.Second; + ticks += totalSeconds * TimeSpan.TicksPerSecond; + ticks += parseData.Fraction; + value = new DateTime(ticks: ticks, kind: kind); + return true; + } + + private static ReadOnlySpan DaysToMonth365 => + [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365]; + private static ReadOnlySpan DaysToMonth366 => + [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366]; + } + + internal static class ThrowHelper + { + private const string ExceptionSourceValueToRethrowAsJsonException = + "System.Text.Json.Rethrowable"; + + [DoesNotReturn] + public static void ThrowInvalidOperationException_ExpectedString(JsonTokenType tokenType) + { + throw GetInvalidOperationException("string", tokenType); + } + + public static void ThrowFormatException(DataType dataType) + { + throw new FormatException(SR.Format(SR.UnsupportedFormat, dataType)) + { + Source = ExceptionSourceValueToRethrowAsJsonException, + }; + } + + private static Exception GetInvalidOperationException( + string message, + JsonTokenType tokenType + ) + { + return GetInvalidOperationException(SR.Format(SR.InvalidCast, tokenType, message)); + } + + private static InvalidOperationException GetInvalidOperationException(string message) + { + return new InvalidOperationException(message) + { + Source = ExceptionSourceValueToRethrowAsJsonException, + }; + } + } + + internal static class Utf8JsonReaderExtensions + { + internal static int ValueLength(this Utf8JsonReader reader) => + reader.HasValueSequence + ? checked((int)reader.ValueSequence.Length) + : reader.ValueSpan.Length; + } + + internal enum DataType + { + TimeOnly, + DateOnly, + } + + [SuppressMessage("ReSharper", "InconsistentNaming")] + internal static class SR + { + private static readonly bool s_usingResourceKeys = + AppContext.TryGetSwitch( + "System.Resources.UseSystemResourceKeys", + out bool usingResourceKeys + ) && usingResourceKeys; + + public static string UnsupportedFormat => Strings.UnsupportedFormat; + + public static string InvalidCast => Strings.InvalidCast; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string Format(string resourceFormat, object? p1) => + s_usingResourceKeys + ? string.Join(", ", resourceFormat, p1) + : string.Format(resourceFormat, p1); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string Format(string resourceFormat, object? p1, object? p2) => + s_usingResourceKeys + ? string.Join(", ", resourceFormat, p1, p2) + : string.Format(resourceFormat, p1, p2); + } + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute( + "System.Resources.Tools.StronglyTypedResourceBuilder", + "17.0.0.0" + )] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Strings + { + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute( + "Microsoft.Performance", + "CA1811:AvoidUncalledPrivateCode" + )] + internal Strings() { } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute( + global::System.ComponentModel.EditorBrowsableState.Advanced + )] + internal static global::System.Resources.ResourceManager ResourceManager + { + get + { + if (object.ReferenceEquals(resourceMan, null)) + { + global::System.Resources.ResourceManager temp = + new global::System.Resources.ResourceManager( + "System.Text.Json.Resources.Strings", + typeof(Strings).Assembly + ); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute( + global::System.ComponentModel.EditorBrowsableState.Advanced + )] + internal static global::System.Globalization.CultureInfo Culture + { + get { return resourceCulture; } + set { resourceCulture = value; } + } + + /// + /// Looks up a localized string similar to Cannot get the value of a token type '{0}' as a {1}.. + /// + internal static string InvalidCast + { + get { return ResourceManager.GetString("InvalidCast", resourceCulture); } + } + + /// + /// Looks up a localized string similar to The JSON value is not in a supported {0} format.. + /// + internal static string UnsupportedFormat + { + get { return ResourceManager.GetString("UnsupportedFormat", resourceCulture); } + } + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/DateTimeSerializer.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/DateTimeSerializer.cs new file mode 100644 index 00000000000..febf9435e0a --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/DateTimeSerializer.cs @@ -0,0 +1,22 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCsharpAccess.Core; + +internal class DateTimeSerializer : JsonConverter +{ + public override DateTime Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + return DateTime.Parse(reader.GetString()!, null, DateTimeStyles.RoundtripKind); + } + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString(Constants.DateTimeFormat)); + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/EnumSerializer.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/EnumSerializer.cs new file mode 100644 index 00000000000..da4570d74f4 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/EnumSerializer.cs @@ -0,0 +1,53 @@ +using System.Runtime.Serialization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCsharpAccess.Core; + +internal class EnumSerializer : JsonConverter + where TEnum : struct, Enum +{ + private readonly Dictionary _enumToString = new(); + private readonly Dictionary _stringToEnum = new(); + + public EnumSerializer() + { + var type = typeof(TEnum); + var values = Enum.GetValues(type); + + foreach (var value in values) + { + var enumValue = (TEnum)value; + var enumMember = type.GetField(enumValue.ToString())!; + var attr = enumMember + .GetCustomAttributes(typeof(EnumMemberAttribute), false) + .Cast() + .FirstOrDefault(); + + var stringValue = + attr?.Value + ?? value.ToString() + ?? throw new Exception("Unexpected null enum toString value"); + + _enumToString.Add(enumValue, stringValue); + _stringToEnum.Add(stringValue, enumValue); + } + } + + public override TEnum Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + var stringValue = + reader.GetString() + ?? throw new Exception("The JSON value could not be read as a string."); + return _stringToEnum.TryGetValue(stringValue, out var enumValue) ? enumValue : default; + } + + public override void Write(Utf8JsonWriter writer, TEnum value, JsonSerializerOptions options) + { + writer.WriteStringValue(_enumToString[value]); + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Extensions.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Extensions.cs new file mode 100644 index 00000000000..98398fd90ba --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Extensions.cs @@ -0,0 +1,14 @@ +using System.Runtime.Serialization; + +namespace SeedCsharpAccess.Core; + +internal static class Extensions +{ + public static string Stringify(this Enum value) + { + var field = value.GetType().GetField(value.ToString()); + var attribute = (EnumMemberAttribute) + Attribute.GetCustomAttribute(field, typeof(EnumMemberAttribute)); + return attribute?.Value ?? value.ToString(); + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/HeaderValue.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/HeaderValue.cs new file mode 100644 index 00000000000..81cf6ef9218 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/HeaderValue.cs @@ -0,0 +1,17 @@ +using OneOf; + +namespace SeedCsharpAccess.Core; + +internal sealed class HeaderValue(OneOf> value) + : OneOfBase>(value) +{ + public static implicit operator HeaderValue(string value) + { + return new HeaderValue(value); + } + + public static implicit operator HeaderValue(Func value) + { + return new HeaderValue(value); + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Headers.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Headers.cs new file mode 100644 index 00000000000..c0c123bd625 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Headers.cs @@ -0,0 +1,17 @@ +namespace SeedCsharpAccess.Core; + +internal sealed class Headers : Dictionary +{ + public Headers() { } + + public Headers(Dictionary value) + { + foreach (var kvp in value) + { + this[kvp.Key] = new HeaderValue(kvp.Value); + } + } + + public Headers(IEnumerable> value) + : base(value.ToDictionary(e => e.Key, e => e.Value)) { } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/HttpMethodExtensions.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/HttpMethodExtensions.cs new file mode 100644 index 00000000000..d114e4933cc --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/HttpMethodExtensions.cs @@ -0,0 +1,8 @@ +using System.Net.Http; + +namespace SeedCsharpAccess.Core; + +internal static class HttpMethodExtensions +{ + public static readonly HttpMethod Patch = new("PATCH"); +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/IRequestOptions.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/IRequestOptions.cs new file mode 100644 index 00000000000..fe5af4aa4de --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/IRequestOptions.cs @@ -0,0 +1,32 @@ +using System; +using System.Net.Http; + +namespace SeedCsharpAccess.Core; + +internal interface IRequestOptions +{ + /// + /// The Base URL for the API. + /// + public string? BaseUrl { get; init; } + + /// + /// The http client used to make requests. + /// + public HttpClient? HttpClient { get; init; } + + /// + /// The http headers sent with the request. + /// + internal Headers Headers { get; init; } + + /// + /// The http client used to make requests. + /// + public int? MaxRetries { get; init; } + + /// + /// The timeout for the request. + /// + public TimeSpan? Timeout { get; init; } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/JsonConfiguration.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/JsonConfiguration.cs new file mode 100644 index 00000000000..be18948464e --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/JsonConfiguration.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace SeedCsharpAccess.Core; + +internal static partial class JsonOptions +{ + public static readonly JsonSerializerOptions JsonSerializerOptions; + + static JsonOptions() + { + var options = new JsonSerializerOptions + { + Converters = + { + new DateTimeSerializer(), +#if USE_PORTABLE_DATE_ONLY + new DateOnlyConverter(), +#endif + new OneOfSerializer(), + }, + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + ConfigureJsonSerializerOptions(options); + JsonSerializerOptions = options; + } + + static partial void ConfigureJsonSerializerOptions(JsonSerializerOptions defaultOptions); +} + +internal static class JsonUtils +{ + public static string Serialize(T obj) + { + return JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); + } + + public static string SerializeAsString(T obj) + { + var json = JsonSerializer.Serialize(obj, JsonOptions.JsonSerializerOptions); + return json.Trim('"'); + } + + public static T Deserialize(string json) + { + return JsonSerializer.Deserialize(json, JsonOptions.JsonSerializerOptions)!; + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/OneOfSerializer.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/OneOfSerializer.cs new file mode 100644 index 00000000000..1a49b1efd55 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/OneOfSerializer.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; +using OneOf; + +namespace SeedCsharpAccess.Core; + +internal class OneOfSerializer : JsonConverter +{ + public override IOneOf? Read( + ref Utf8JsonReader reader, + global::System.Type typeToConvert, + JsonSerializerOptions options + ) + { + if (reader.TokenType is JsonTokenType.Null) + return default; + + foreach (var (type, cast) in GetOneOfTypes(typeToConvert)) + { + try + { + var readerCopy = reader; + var result = JsonSerializer.Deserialize(ref readerCopy, type, options); + reader.Skip(); + return (IOneOf)cast.Invoke(null, [result])!; + } + catch (JsonException) { } + } + + throw new JsonException( + $"Cannot deserialize into one of the supported types for {typeToConvert}" + ); + } + + public override void Write(Utf8JsonWriter writer, IOneOf value, JsonSerializerOptions options) + { + JsonSerializer.Serialize(writer, value.Value, options); + } + + private static (global::System.Type type, MethodInfo cast)[] GetOneOfTypes( + global::System.Type typeToConvert + ) + { + var type = typeToConvert; + if (Nullable.GetUnderlyingType(type) is { } underlyingType) + { + type = underlyingType; + } + + var casts = type.GetRuntimeMethods() + .Where(m => m.IsSpecialName && m.Name == "op_Implicit") + .ToArray(); + while (type != null) + { + if ( + type.IsGenericType + && (type.Name.StartsWith("OneOf`") || type.Name.StartsWith("OneOfBase`")) + ) + { + var genericArguments = type.GetGenericArguments(); + if (genericArguments.Length == 1) + { + return [(genericArguments[0], casts[0])]; + } + + // if object type is present, make sure it is last + var indexOfObjectType = Array.IndexOf(genericArguments, typeof(object)); + if (indexOfObjectType != -1 && genericArguments.Length - 1 != indexOfObjectType) + { + genericArguments = genericArguments + .OrderBy(t => t == typeof(object) ? 1 : 0) + .ToArray(); + } + + return genericArguments + .Select(t => (t, casts.First(c => c.GetParameters()[0].ParameterType == t))) + .ToArray(); + } + + type = type.BaseType; + } + + throw new InvalidOperationException($"{type} isn't OneOf or OneOfBase"); + } + + public override bool CanConvert(global::System.Type typeToConvert) + { + return typeof(IOneOf).IsAssignableFrom(typeToConvert); + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/ClientOptions.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/ClientOptions.cs new file mode 100644 index 00000000000..82e09d217fd --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/ClientOptions.cs @@ -0,0 +1,48 @@ +using System; +using System.Net.Http; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess; + +public partial class ClientOptions +{ + /// + /// The Base URL for the API. + /// + public string BaseUrl { get; init; } = ""; + + /// + /// The http client used to make requests. + /// + public HttpClient HttpClient { get; init; } = new HttpClient(); + + /// + /// The http client used to make requests. + /// + public int MaxRetries { get; init; } = 2; + + /// + /// The timeout for the request. + /// + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// The http headers sent with the request. + /// + internal Headers Headers { get; init; } = new(); + + /// + /// Clones this and returns a new instance + /// + internal ClientOptions Clone() + { + return new ClientOptions + { + BaseUrl = BaseUrl, + HttpClient = HttpClient, + MaxRetries = MaxRetries, + Timeout = Timeout, + Headers = new Headers(new Dictionary(Headers)), + }; + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/RequestOptions.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/RequestOptions.cs new file mode 100644 index 00000000000..292a5840665 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/RequestOptions.cs @@ -0,0 +1,33 @@ +using System; +using System.Net.Http; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess; + +public partial class RequestOptions : IRequestOptions +{ + /// + /// The Base URL for the API. + /// + public string? BaseUrl { get; init; } + + /// + /// The http client used to make requests. + /// + public HttpClient? HttpClient { get; init; } + + /// + /// The http client used to make requests. + /// + public int? MaxRetries { get; init; } + + /// + /// The timeout for the request. + /// + public TimeSpan? Timeout { get; init; } + + /// + /// The http headers sent with the request. + /// + Headers IRequestOptions.Headers { get; init; } = new(); +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/SeedCsharpAccessApiException.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/SeedCsharpAccessApiException.cs new file mode 100644 index 00000000000..cc1b689cd71 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/SeedCsharpAccessApiException.cs @@ -0,0 +1,18 @@ +namespace SeedCsharpAccess; + +/// +/// This exception type will be thrown for any non-2XX API responses. +/// +public class SeedCsharpAccessApiException(string message, int statusCode, object body) + : SeedCsharpAccessException(message) +{ + /// + /// The error code of the response that triggered the exception. + /// + public int StatusCode => statusCode; + + /// + /// The body of the response that triggered the exception. + /// + public object Body => body; +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/SeedCsharpAccessException.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/SeedCsharpAccessException.cs new file mode 100644 index 00000000000..2da94367f07 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/SeedCsharpAccessException.cs @@ -0,0 +1,9 @@ +using System; + +namespace SeedCsharpAccess; + +/// +/// Base exception class for all exceptions thrown by the SDK. +/// +public class SeedCsharpAccessException(string message, Exception? innerException = null) + : Exception(message, innerException); diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/Version.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/Version.cs new file mode 100644 index 00000000000..1fe989f5c6e --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/Public/Version.cs @@ -0,0 +1,6 @@ +namespace SeedCsharpAccess; + +internal class Version +{ + public const string Current = "0.0.1"; +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/RawClient.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/RawClient.cs new file mode 100644 index 00000000000..7b4b6157b6f --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Core/RawClient.cs @@ -0,0 +1,196 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using SystemTask = System.Threading.Tasks.Task; + +namespace SeedCsharpAccess.Core; + +/// +/// Utility class for making raw HTTP requests to the API. +/// +internal class RawClient(ClientOptions clientOptions) +{ + private const int MaxRetryDelayMs = 60000; + internal int BaseRetryDelay { get; set; } = 1000; + + /// + /// The client options applied on every request. + /// + public readonly ClientOptions Options = clientOptions; + + public async Task MakeRequestAsync( + BaseApiRequest request, + CancellationToken cancellationToken = default + ) + { + // Apply the request timeout. + var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var timeout = request.Options?.Timeout ?? Options.Timeout; + cts.CancelAfter(timeout); + + // Send the request. + return await SendWithRetriesAsync(request, cts.Token).ConfigureAwait(false); + } + + public record BaseApiRequest + { + public required string BaseUrl { get; init; } + + public required HttpMethod Method { get; init; } + + public required string Path { get; init; } + + public string? ContentType { get; init; } + + public Dictionary Query { get; init; } = new(); + + public Headers Headers { get; init; } = new(); + + public IRequestOptions? Options { get; init; } + } + + /// + /// The request object to be sent for streaming uploads. + /// + public record StreamApiRequest : BaseApiRequest + { + public Stream? Body { get; init; } + } + + /// + /// The request object to be sent for JSON APIs. + /// + public record JsonApiRequest : BaseApiRequest + { + public object? Body { get; init; } + } + + /// + /// The response object returned from the API. + /// + public record ApiResponse + { + public required int StatusCode { get; init; } + + public required HttpResponseMessage Raw { get; init; } + } + + private async Task SendWithRetriesAsync( + BaseApiRequest request, + CancellationToken cancellationToken + ) + { + var httpClient = request.Options?.HttpClient ?? Options.HttpClient; + var maxRetries = request.Options?.MaxRetries ?? Options.MaxRetries; + var response = await httpClient + .SendAsync(BuildHttpRequest(request), cancellationToken) + .ConfigureAwait(false); + for (var i = 0; i < maxRetries; i++) + { + if (!ShouldRetry(response)) + { + break; + } + var delayMs = Math.Min(BaseRetryDelay * (int)Math.Pow(2, i), MaxRetryDelayMs); + await SystemTask.Delay(delayMs, cancellationToken).ConfigureAwait(false); + response = await httpClient + .SendAsync(BuildHttpRequest(request), cancellationToken) + .ConfigureAwait(false); + } + return new ApiResponse { StatusCode = (int)response.StatusCode, Raw = response }; + } + + private static bool ShouldRetry(HttpResponseMessage response) + { + var statusCode = (int)response.StatusCode; + return statusCode is 408 or 429 or >= 500; + } + + private HttpRequestMessage BuildHttpRequest(BaseApiRequest request) + { + var url = BuildUrl(request); + var httpRequest = new HttpRequestMessage(request.Method, url); + switch (request) + { + // Add the request body to the request. + case JsonApiRequest jsonRequest: + { + if (jsonRequest.Body != null) + { + httpRequest.Content = new StringContent( + JsonUtils.Serialize(jsonRequest.Body), + Encoding.UTF8, + "application/json" + ); + } + break; + } + case StreamApiRequest { Body: not null } streamRequest: + httpRequest.Content = new StreamContent(streamRequest.Body); + break; + } + if (request.ContentType != null) + { + httpRequest.Content.Headers.ContentType = MediaTypeHeaderValue.Parse( + request.ContentType + ); + } + SetHeaders(httpRequest, Options.Headers); + SetHeaders(httpRequest, request.Headers); + SetHeaders(httpRequest, request.Options?.Headers ?? new Headers()); + + return httpRequest; + } + + private static string BuildUrl(BaseApiRequest request) + { + var baseUrl = request.Options?.BaseUrl ?? request.BaseUrl; + var trimmedBaseUrl = baseUrl.TrimEnd('/'); + var trimmedBasePath = request.Path.TrimStart('/'); + var url = $"{trimmedBaseUrl}/{trimmedBasePath}"; + if (request.Query.Count <= 0) + return url; + url += "?"; + url = request.Query.Aggregate( + url, + (current, queryItem) => + { + if ( + queryItem.Value + is global::System.Collections.IEnumerable collection + and not string + ) + { + var items = collection + .Cast() + .Select(value => $"{queryItem.Key}={value}") + .ToList(); + if (items.Any()) + { + current += string.Join("&", items) + "&"; + } + } + else + { + current += $"{queryItem.Key}={queryItem.Value}&"; + } + return current; + } + ); + url = url[..^1]; + return url; + } + + private static void SetHeaders(HttpRequestMessage httpRequest, Headers headers) + { + foreach (var header in headers) + { + var value = header.Value?.Match(str => str, func => func.Invoke()); + if (value != null) + { + httpRequest.Headers.TryAddWithoutValidation(header.Key, value); + } + } + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.Custom.props b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.Custom.props new file mode 100644 index 00000000000..70df2849401 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.Custom.props @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.csproj b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.csproj new file mode 100644 index 00000000000..a6fb26489a0 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccess.csproj @@ -0,0 +1,53 @@ + + + + net462;net8.0;net7.0;net6.0;netstandard2.0 + enable + 12 + enable + 0.0.1 + $(Version) + $(Version) + README.md + https://github.com/csharp-property-access/fern + true + + + + false + + + $(DefineConstants);USE_PORTABLE_DATE_ONLY + true + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + <_Parameter1>SeedCsharpAccess.Test + + + + + diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccessClient.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccessClient.cs new file mode 100644 index 00000000000..22e4f994a35 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/SeedCsharpAccessClient.cs @@ -0,0 +1,85 @@ +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess; + +public partial class SeedCsharpAccessClient +{ + private readonly RawClient _client; + + public SeedCsharpAccessClient(ClientOptions? clientOptions = null) + { + var defaultHeaders = new Headers( + new Dictionary() + { + { "X-Fern-Language", "C#" }, + { "X-Fern-SDK-Name", "SeedCsharpAccess" }, + { "X-Fern-SDK-Version", Version.Current }, + { "User-Agent", "Ferncsharp-property-access/0.0.1" }, + } + ); + clientOptions ??= new ClientOptions(); + foreach (var header in defaultHeaders) + { + if (!clientOptions.Headers.ContainsKey(header.Key)) + { + clientOptions.Headers[header.Key] = header.Value; + } + } + _client = new RawClient(clientOptions); + } + + /// + /// + /// await client.CreateUserAsync( + /// new User + /// { + /// Id = "id", + /// Name = "name", + /// Email = "email", + /// Password = "password", + /// } + /// ); + /// + /// + public async Task CreateUserAsync( + User request, + RequestOptions? options = null, + CancellationToken cancellationToken = default + ) + { + var response = await _client + .MakeRequestAsync( + new RawClient.JsonApiRequest + { + BaseUrl = _client.Options.BaseUrl, + Method = HttpMethod.Post, + Path = "/users", + Body = request, + Options = options, + }, + cancellationToken + ) + .ConfigureAwait(false); + var responseBody = await response.Raw.Content.ReadAsStringAsync(); + if (response.StatusCode is >= 200 and < 400) + { + try + { + return JsonUtils.Deserialize(responseBody)!; + } + catch (JsonException e) + { + throw new SeedCsharpAccessException("Failed to deserialize response", e); + } + } + + throw new SeedCsharpAccessApiException( + $"Error with status code {response.StatusCode}", + response.StatusCode, + responseBody + ); + } +} diff --git a/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Types/User.cs b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Types/User.cs new file mode 100644 index 00000000000..ee4caffccf8 --- /dev/null +++ b/seed/csharp-sdk/csharp-property-access/src/SeedCsharpAccess/Types/User.cs @@ -0,0 +1,24 @@ +using System.Text.Json.Serialization; +using SeedCsharpAccess.Core; + +namespace SeedCsharpAccess; + +public record User +{ + [JsonPropertyName("id")] + public required string Id { get; set; } + + [JsonPropertyName("name")] + public required string Name { get; set; } + + [JsonPropertyName("email")] + public required string Email { get; set; } + + [JsonPropertyName("password")] + public required string Password { get; set; } + + public override string ToString() + { + return JsonUtils.Serialize(this); + } +} diff --git a/test-definitions/fern/apis/csharp-property-access/definition/__package__.yml b/test-definitions/fern/apis/csharp-property-access/definition/__package__.yml new file mode 100644 index 00000000000..163885d207e --- /dev/null +++ b/test-definitions/fern/apis/csharp-property-access/definition/__package__.yml @@ -0,0 +1,21 @@ +types: + User: + properties: + id: + type: string + access: read-only + name: string + email: string + password: + type: string + access: write-only + +service: + auth: false + base-path: /users + endpoints: + createUser: + method: POST + path: "" + request: User + response: User \ No newline at end of file diff --git a/test-definitions/fern/apis/csharp-property-access/definition/api.yml b/test-definitions/fern/apis/csharp-property-access/definition/api.yml new file mode 100644 index 00000000000..773f9b4ea27 --- /dev/null +++ b/test-definitions/fern/apis/csharp-property-access/definition/api.yml @@ -0,0 +1 @@ +name: csharp-access diff --git a/test-definitions/fern/apis/csharp-property-access/generators.yml b/test-definitions/fern/apis/csharp-property-access/generators.yml new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/test-definitions/fern/apis/csharp-property-access/generators.yml @@ -0,0 +1 @@ +{}